altium-toolkit 1.1.26 → 1.1.30
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/package.json +1 -1
- package/src/ui/AltiumScene3dBottomPadRotationAdapter.mjs +127 -0
- package/src/ui/AltiumScene3dComponentBodyAdapter.mjs +153 -0
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +919 -0
- package/src/ui/AltiumScene3dIdentityTokens.mjs +105 -0
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +357 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +576 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +54 -11
- package/src/ui/PcbScene3dBuilder.mjs +69 -32
- package/src/ui/PcbScene3dPadLocalSpanResolver.mjs +109 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
const CONNECTOR_TOKENS = new Set([
|
|
2
|
+
'antenna',
|
|
3
|
+
'coax',
|
|
4
|
+
'connector',
|
|
5
|
+
'edge',
|
|
6
|
+
'rf',
|
|
7
|
+
'socket'
|
|
8
|
+
])
|
|
9
|
+
const PASSIVE_BODY_PATTERN =
|
|
10
|
+
/(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|lqw|lqg)(?:$|[^a-z0-9])/i
|
|
11
|
+
const TIMING_PACKAGE_PATTERN =
|
|
12
|
+
/(?:^|[^a-z0-9])(?:clock|crystal|osc|oscillator|resonator|tcxo|txco|xtal)(?:$|[^a-z0-9])/i
|
|
13
|
+
const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Repairs repeated Altium model-anchor bodies by matching their shared source
|
|
17
|
+
* origin offset to repeated compatible footprint owners.
|
|
18
|
+
*/
|
|
19
|
+
export class AltiumScene3dRepeatedModelOwnerRepair {
|
|
20
|
+
static #OFFSET_TOLERANCE_MIL = 8
|
|
21
|
+
static #MIN_OWNER_DISTANCE_MIL = 25
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Applies repeated-model owner repair to an Altium scene.
|
|
25
|
+
* @param {object} sceneDescription Scene description.
|
|
26
|
+
* @param {object} documentModel Source document model.
|
|
27
|
+
* @returns {object}
|
|
28
|
+
*/
|
|
29
|
+
static apply(sceneDescription, documentModel) {
|
|
30
|
+
if (
|
|
31
|
+
String(sceneDescription?.sourceFormat || '').toLowerCase() !==
|
|
32
|
+
'altium' ||
|
|
33
|
+
!Array.isArray(sceneDescription?.externalPlacements)
|
|
34
|
+
) {
|
|
35
|
+
return sceneDescription
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const components = Array.isArray(documentModel?.pcb?.components)
|
|
39
|
+
? documentModel.pcb.components
|
|
40
|
+
: []
|
|
41
|
+
if (!components.length) {
|
|
42
|
+
return sceneDescription
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const componentByDesignator = new Map(
|
|
46
|
+
components.map((component) => [
|
|
47
|
+
String(component?.designator || ''),
|
|
48
|
+
component
|
|
49
|
+
])
|
|
50
|
+
)
|
|
51
|
+
const placements = sceneDescription.externalPlacements.map(
|
|
52
|
+
(placement) =>
|
|
53
|
+
AltiumScene3dRepeatedModelOwnerRepair.#withPassiveOwnerCenter(
|
|
54
|
+
placement,
|
|
55
|
+
componentByDesignator.get(
|
|
56
|
+
String(placement?.designator || '')
|
|
57
|
+
),
|
|
58
|
+
sceneDescription.board
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
for (const group of AltiumScene3dRepeatedModelOwnerRepair.#groups(
|
|
63
|
+
placements,
|
|
64
|
+
componentByDesignator
|
|
65
|
+
)) {
|
|
66
|
+
const matches =
|
|
67
|
+
AltiumScene3dRepeatedModelOwnerRepair.#matchGroupOwners(
|
|
68
|
+
group.records,
|
|
69
|
+
components
|
|
70
|
+
)
|
|
71
|
+
if (!matches) {
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const match of matches) {
|
|
76
|
+
placements[match.record.index] =
|
|
77
|
+
AltiumScene3dRepeatedModelOwnerRepair.#withOwner(
|
|
78
|
+
match.record.placement,
|
|
79
|
+
match.component,
|
|
80
|
+
sceneDescription.board,
|
|
81
|
+
match.offset
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ...sceneDescription, externalPlacements: placements }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Groups repairable repeated model-anchor placements.
|
|
91
|
+
* @param {object[]} placements Scene placements.
|
|
92
|
+
* @param {Map<string, object>} componentByDesignator Components by designator.
|
|
93
|
+
* @returns {{ records: { index: number, placement: object }[] }[]}
|
|
94
|
+
*/
|
|
95
|
+
static #groups(placements, componentByDesignator) {
|
|
96
|
+
const groups = new Map()
|
|
97
|
+
|
|
98
|
+
placements.forEach((placement, index) => {
|
|
99
|
+
if (
|
|
100
|
+
!AltiumScene3dRepeatedModelOwnerRepair.#isRepairablePlacement(
|
|
101
|
+
placement,
|
|
102
|
+
componentByDesignator
|
|
103
|
+
)
|
|
104
|
+
) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const key =
|
|
109
|
+
[
|
|
110
|
+
String(placement?.externalModel?.name || ''),
|
|
111
|
+
String(placement?.mountSide || '').toLowerCase(),
|
|
112
|
+
AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
|
|
113
|
+
placement?.rotationDeg
|
|
114
|
+
)
|
|
115
|
+
].join('|') || 'model'
|
|
116
|
+
const records = groups.get(key) || []
|
|
117
|
+
records.push({ index, placement })
|
|
118
|
+
groups.set(key, records)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return [...groups.values()]
|
|
122
|
+
.filter((records) => records.length > 1)
|
|
123
|
+
.map((records) => ({ records }))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Checks whether one placement should participate in group owner repair.
|
|
128
|
+
* @param {object} placement Scene placement.
|
|
129
|
+
* @param {Map<string, object>} componentByDesignator Components by designator.
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
static #isRepairablePlacement(placement, componentByDesignator) {
|
|
133
|
+
if (
|
|
134
|
+
String(placement?.projection?.source || '') !==
|
|
135
|
+
'model-anchor-fallback' ||
|
|
136
|
+
!placement?.bodyPositionMil ||
|
|
137
|
+
!placement?.positionMil ||
|
|
138
|
+
!placement?.externalModel
|
|
139
|
+
) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const component = componentByDesignator.get(
|
|
144
|
+
String(placement?.designator || '')
|
|
145
|
+
)
|
|
146
|
+
if (!component) {
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
AltiumScene3dRepeatedModelOwnerRepair.#distance(
|
|
152
|
+
placement.bodyPositionMil,
|
|
153
|
+
component
|
|
154
|
+
) > AltiumScene3dRepeatedModelOwnerRepair.#MIN_OWNER_DISTANCE_MIL
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Centers a generic passive body on its resolved owner when the body anchor
|
|
160
|
+
* carries a moderate source-origin offset.
|
|
161
|
+
* @param {object} placement Scene placement.
|
|
162
|
+
* @param {object | undefined} component Resolved owner.
|
|
163
|
+
* @param {object} board Scene board metadata.
|
|
164
|
+
* @returns {object}
|
|
165
|
+
*/
|
|
166
|
+
static #withPassiveOwnerCenter(placement, component, board) {
|
|
167
|
+
if (
|
|
168
|
+
!component ||
|
|
169
|
+
String(placement?.projection?.source || '') !== 'pad-fallback' ||
|
|
170
|
+
!AltiumScene3dRepeatedModelOwnerRepair.#isPassivePlacement(
|
|
171
|
+
placement,
|
|
172
|
+
component
|
|
173
|
+
)
|
|
174
|
+
) {
|
|
175
|
+
return placement
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const offset = {
|
|
179
|
+
x:
|
|
180
|
+
Number(placement?.bodyPositionMil?.x || 0) -
|
|
181
|
+
Number(component?.x || 0),
|
|
182
|
+
y:
|
|
183
|
+
Number(placement?.bodyPositionMil?.y || 0) -
|
|
184
|
+
Number(component?.y || 0)
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
Math.hypot(offset.x, offset.y) <=
|
|
188
|
+
AltiumScene3dRepeatedModelOwnerRepair.#MIN_OWNER_DISTANCE_MIL
|
|
189
|
+
) {
|
|
190
|
+
return placement
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return AltiumScene3dRepeatedModelOwnerRepair.#withOwner(
|
|
194
|
+
placement,
|
|
195
|
+
component,
|
|
196
|
+
board,
|
|
197
|
+
offset
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Checks whether a placement/component pair describes a generic passive.
|
|
203
|
+
* @param {object} placement Scene placement.
|
|
204
|
+
* @param {object} component PCB component.
|
|
205
|
+
* @returns {boolean}
|
|
206
|
+
*/
|
|
207
|
+
static #isPassivePlacement(placement, component) {
|
|
208
|
+
return PASSIVE_BODY_PATTERN.test(
|
|
209
|
+
[
|
|
210
|
+
placement?.designator,
|
|
211
|
+
placement?.externalModel?.name,
|
|
212
|
+
component?.pattern,
|
|
213
|
+
component?.source,
|
|
214
|
+
component?.description
|
|
215
|
+
]
|
|
216
|
+
.map((value) => String(value || ''))
|
|
217
|
+
.join(' ')
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Matches one repeated placement group to compatible components.
|
|
223
|
+
* @param {{ placement: object }[]} records Group records.
|
|
224
|
+
* @param {object[]} components PCB components.
|
|
225
|
+
* @returns {{ record: object, component: object, offset: object }[] | null}
|
|
226
|
+
*/
|
|
227
|
+
static #matchGroupOwners(records, components) {
|
|
228
|
+
const candidates =
|
|
229
|
+
AltiumScene3dRepeatedModelOwnerRepair.#compatibleComponents(
|
|
230
|
+
records[0]?.placement,
|
|
231
|
+
components
|
|
232
|
+
)
|
|
233
|
+
const connectorMatches =
|
|
234
|
+
AltiumScene3dRepeatedModelOwnerRepair.#matchCandidateOwners(
|
|
235
|
+
records,
|
|
236
|
+
candidates
|
|
237
|
+
)
|
|
238
|
+
if (connectorMatches) {
|
|
239
|
+
return connectorMatches
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return AltiumScene3dRepeatedModelOwnerRepair.#matchCandidateOwners(
|
|
243
|
+
records,
|
|
244
|
+
AltiumScene3dRepeatedModelOwnerRepair.#compatibleTimingComponents(
|
|
245
|
+
records[0]?.placement,
|
|
246
|
+
components
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Matches records against one candidate component set.
|
|
253
|
+
* @param {{ placement: object }[]} records Group records.
|
|
254
|
+
* @param {object[]} candidates Candidate components.
|
|
255
|
+
* @returns {{ record: object, component: object, offset: object }[] | null}
|
|
256
|
+
*/
|
|
257
|
+
static #matchCandidateOwners(records, candidates) {
|
|
258
|
+
if (candidates.length < records.length) {
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const attempts = records.flatMap((record) =>
|
|
263
|
+
candidates.map((component) => ({
|
|
264
|
+
x:
|
|
265
|
+
Number(record.placement.bodyPositionMil?.x || 0) -
|
|
266
|
+
Number(component?.x || 0),
|
|
267
|
+
y:
|
|
268
|
+
Number(record.placement.bodyPositionMil?.y || 0) -
|
|
269
|
+
Number(component?.y || 0)
|
|
270
|
+
}))
|
|
271
|
+
)
|
|
272
|
+
const matches = attempts
|
|
273
|
+
.map((offset) => ({
|
|
274
|
+
offset,
|
|
275
|
+
matches:
|
|
276
|
+
AltiumScene3dRepeatedModelOwnerRepair.#matchesForOffset(
|
|
277
|
+
records,
|
|
278
|
+
candidates,
|
|
279
|
+
offset
|
|
280
|
+
)
|
|
281
|
+
}))
|
|
282
|
+
.filter((attempt) => attempt.matches.length === records.length)
|
|
283
|
+
.sort(
|
|
284
|
+
(left, right) =>
|
|
285
|
+
AltiumScene3dRepeatedModelOwnerRepair.#matchError(
|
|
286
|
+
left.matches
|
|
287
|
+
) -
|
|
288
|
+
AltiumScene3dRepeatedModelOwnerRepair.#matchError(
|
|
289
|
+
right.matches
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return matches[0]?.matches || null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Resolves compatible components for one placement group.
|
|
298
|
+
* @param {object} placement Representative placement.
|
|
299
|
+
* @param {object[]} components PCB components.
|
|
300
|
+
* @returns {object[]}
|
|
301
|
+
*/
|
|
302
|
+
static #compatibleComponents(placement, components) {
|
|
303
|
+
const mountSide = String(placement?.mountSide || '').toLowerCase()
|
|
304
|
+
const rotation = AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
|
|
305
|
+
placement?.rotationDeg
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return components.filter(
|
|
309
|
+
(component) =>
|
|
310
|
+
AltiumScene3dRepeatedModelOwnerRepair.#mountSide(component) ===
|
|
311
|
+
mountSide &&
|
|
312
|
+
AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
|
|
313
|
+
component?.rotation
|
|
314
|
+
) === rotation &&
|
|
315
|
+
AltiumScene3dRepeatedModelOwnerRepair.#connectorScore(
|
|
316
|
+
component
|
|
317
|
+
) >= 2
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Resolves timing-package candidates for repeated bodies with shared
|
|
323
|
+
* source-origin offsets.
|
|
324
|
+
* @param {object} placement Representative placement.
|
|
325
|
+
* @param {object[]} components PCB components.
|
|
326
|
+
* @returns {object[]}
|
|
327
|
+
*/
|
|
328
|
+
static #compatibleTimingComponents(placement, components) {
|
|
329
|
+
if (
|
|
330
|
+
!AltiumScene3dRepeatedModelOwnerRepair.#isTimingPlacement(placement)
|
|
331
|
+
) {
|
|
332
|
+
return []
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const mountSide = String(placement?.mountSide || '').toLowerCase()
|
|
336
|
+
const rotation = AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
|
|
337
|
+
placement?.rotationDeg
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return components.filter(
|
|
341
|
+
(component) =>
|
|
342
|
+
AltiumScene3dRepeatedModelOwnerRepair.#mountSide(component) ===
|
|
343
|
+
mountSide &&
|
|
344
|
+
AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
|
|
345
|
+
component?.rotation
|
|
346
|
+
) === rotation &&
|
|
347
|
+
AltiumScene3dRepeatedModelOwnerRepair.#isTimingComponent(
|
|
348
|
+
component
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Matches records to components for one candidate shared offset.
|
|
355
|
+
* @param {{ placement: object }[]} records Group records.
|
|
356
|
+
* @param {object[]} components Compatible components.
|
|
357
|
+
* @param {{ x: number, y: number }} offset Candidate offset.
|
|
358
|
+
* @returns {{ record: object, component: object, offset: object, error: number }[]}
|
|
359
|
+
*/
|
|
360
|
+
static #matchesForOffset(records, components, offset) {
|
|
361
|
+
const unused = new Set(components)
|
|
362
|
+
const matches = []
|
|
363
|
+
|
|
364
|
+
for (const record of records) {
|
|
365
|
+
const match =
|
|
366
|
+
AltiumScene3dRepeatedModelOwnerRepair.#nearestOffsetComponent(
|
|
367
|
+
record.placement,
|
|
368
|
+
unused,
|
|
369
|
+
offset
|
|
370
|
+
)
|
|
371
|
+
if (!match) {
|
|
372
|
+
return matches
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
unused.delete(match.component)
|
|
376
|
+
matches.push({ record, ...match, offset })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return matches
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Finds the unused component whose center plus offset reaches a body.
|
|
384
|
+
* @param {object} placement Scene placement.
|
|
385
|
+
* @param {Set<object>} components Unused compatible components.
|
|
386
|
+
* @param {{ x: number, y: number }} offset Candidate offset.
|
|
387
|
+
* @returns {{ component: object, error: number } | null}
|
|
388
|
+
*/
|
|
389
|
+
static #nearestOffsetComponent(placement, components, offset) {
|
|
390
|
+
const body = placement?.bodyPositionMil || {}
|
|
391
|
+
const matches = [...components]
|
|
392
|
+
.map((component) => ({
|
|
393
|
+
component,
|
|
394
|
+
error: Math.hypot(
|
|
395
|
+
Number(component?.x || 0) +
|
|
396
|
+
Number(offset?.x || 0) -
|
|
397
|
+
Number(body.x || 0),
|
|
398
|
+
Number(component?.y || 0) +
|
|
399
|
+
Number(offset?.y || 0) -
|
|
400
|
+
Number(body.y || 0)
|
|
401
|
+
)
|
|
402
|
+
}))
|
|
403
|
+
.filter(
|
|
404
|
+
(match) =>
|
|
405
|
+
match.error <=
|
|
406
|
+
AltiumScene3dRepeatedModelOwnerRepair.#OFFSET_TOLERANCE_MIL
|
|
407
|
+
)
|
|
408
|
+
.sort((left, right) => left.error - right.error)
|
|
409
|
+
|
|
410
|
+
return matches[0] || null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Sums offset match error.
|
|
415
|
+
* @param {{ error: number }[]} matches Offset matches.
|
|
416
|
+
* @returns {number}
|
|
417
|
+
*/
|
|
418
|
+
static #matchError(matches) {
|
|
419
|
+
return matches.reduce(
|
|
420
|
+
(total, match) => total + Number(match?.error || 0),
|
|
421
|
+
0
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Applies a resolved component owner and centers the model on it.
|
|
427
|
+
* @param {object} placement Scene placement.
|
|
428
|
+
* @param {object} component Resolved owner.
|
|
429
|
+
* @param {object} board Scene board metadata.
|
|
430
|
+
* @param {{ x: number, y: number }} offset Source-origin offset.
|
|
431
|
+
* @returns {object}
|
|
432
|
+
*/
|
|
433
|
+
static #withOwner(placement, component, board, offset) {
|
|
434
|
+
const mountSide =
|
|
435
|
+
AltiumScene3dRepeatedModelOwnerRepair.#mountSide(component) ||
|
|
436
|
+
placement.mountSide
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
...placement,
|
|
440
|
+
designator: String(component?.designator || placement.designator),
|
|
441
|
+
mountSide,
|
|
442
|
+
rotationDeg: AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
|
|
443
|
+
component?.rotation
|
|
444
|
+
),
|
|
445
|
+
positionMil: {
|
|
446
|
+
...placement.positionMil,
|
|
447
|
+
x: Number(component?.x || 0) - Number(board?.centerX || 0),
|
|
448
|
+
y: Number(component?.y || 0) - Number(board?.centerY || 0),
|
|
449
|
+
z: AltiumScene3dRepeatedModelOwnerRepair.#faceZ(
|
|
450
|
+
mountSide,
|
|
451
|
+
board
|
|
452
|
+
)
|
|
453
|
+
},
|
|
454
|
+
modelTransform: {
|
|
455
|
+
...(placement.modelTransform || {}),
|
|
456
|
+
ownerAnchorOffsetMil: {
|
|
457
|
+
x: Number(offset?.x || 0),
|
|
458
|
+
y: Number(offset?.y || 0)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Scores connector-like identity tokens on one component.
|
|
466
|
+
* @param {object} component PCB component.
|
|
467
|
+
* @returns {number}
|
|
468
|
+
*/
|
|
469
|
+
static #connectorScore(component) {
|
|
470
|
+
return new Set(
|
|
471
|
+
AltiumScene3dRepeatedModelOwnerRepair.#identityText(component)
|
|
472
|
+
.split(/[^a-zA-Z0-9]+/gu)
|
|
473
|
+
.map((token) => token.toLowerCase())
|
|
474
|
+
.filter((token) => CONNECTOR_TOKENS.has(token))
|
|
475
|
+
).size
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Checks whether one placement identifies a timing package model.
|
|
480
|
+
* @param {object} placement External model placement.
|
|
481
|
+
* @returns {boolean}
|
|
482
|
+
*/
|
|
483
|
+
static #isTimingPlacement(placement) {
|
|
484
|
+
return TIMING_PACKAGE_PATTERN.test(
|
|
485
|
+
[
|
|
486
|
+
placement?.designator,
|
|
487
|
+
placement?.externalModel?.name,
|
|
488
|
+
placement?.externalModel?.sourceStream
|
|
489
|
+
]
|
|
490
|
+
.map((value) => String(value || ''))
|
|
491
|
+
.join(' ')
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Checks whether one component is a timing package owner.
|
|
497
|
+
* @param {object} component PCB component.
|
|
498
|
+
* @returns {boolean}
|
|
499
|
+
*/
|
|
500
|
+
static #isTimingComponent(component) {
|
|
501
|
+
const designator = String(component?.designator || '').trim()
|
|
502
|
+
|
|
503
|
+
return (
|
|
504
|
+
TIMING_DESIGNATOR_PATTERN.test(designator) ||
|
|
505
|
+
TIMING_PACKAGE_PATTERN.test(
|
|
506
|
+
AltiumScene3dRepeatedModelOwnerRepair.#identityText(component)
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Builds component identity text.
|
|
513
|
+
* @param {object} component PCB component.
|
|
514
|
+
* @returns {string}
|
|
515
|
+
*/
|
|
516
|
+
static #identityText(component) {
|
|
517
|
+
return [
|
|
518
|
+
component?.pattern,
|
|
519
|
+
component?.source,
|
|
520
|
+
component?.description,
|
|
521
|
+
...Object.values(component?.parameters || {})
|
|
522
|
+
]
|
|
523
|
+
.map((value) => String(value || ''))
|
|
524
|
+
.join(' ')
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Resolves one component's mount side.
|
|
529
|
+
* @param {object} component PCB component.
|
|
530
|
+
* @returns {'top' | 'bottom' | ''}
|
|
531
|
+
*/
|
|
532
|
+
static #mountSide(component) {
|
|
533
|
+
const layer = String(component?.layer || '').toUpperCase()
|
|
534
|
+
|
|
535
|
+
return layer.includes('BOTTOM') || layer === 'BOT' ? 'bottom' : 'top'
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Measures XY distance between two board points.
|
|
540
|
+
* @param {object} first First point.
|
|
541
|
+
* @param {object} second Second point.
|
|
542
|
+
* @returns {number}
|
|
543
|
+
*/
|
|
544
|
+
static #distance(first, second) {
|
|
545
|
+
return Math.hypot(
|
|
546
|
+
Number(first?.x || 0) - Number(second?.x || 0),
|
|
547
|
+
Number(first?.y || 0) - Number(second?.y || 0)
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Resolves one board face Z coordinate.
|
|
553
|
+
* @param {string} mountSide Mount side.
|
|
554
|
+
* @param {object} board Board metadata.
|
|
555
|
+
* @returns {number}
|
|
556
|
+
*/
|
|
557
|
+
static #faceZ(mountSide, board) {
|
|
558
|
+
const thickness = Number(board?.thicknessMil) || 63
|
|
559
|
+
const halfThickness = thickness / 2
|
|
560
|
+
|
|
561
|
+
return String(mountSide || '').toLowerCase() === 'bottom'
|
|
562
|
+
? -halfThickness
|
|
563
|
+
: halfThickness
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Normalizes an angle into [0, 360).
|
|
568
|
+
* @param {number} angle Source angle.
|
|
569
|
+
* @returns {number}
|
|
570
|
+
*/
|
|
571
|
+
static #normalizeAngle(angle) {
|
|
572
|
+
const normalized = Number(angle || 0) % 360
|
|
573
|
+
|
|
574
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
575
|
+
}
|
|
576
|
+
}
|
|
@@ -202,29 +202,38 @@ export class PcbScene3dBoardOutlineRefiner {
|
|
|
202
202
|
.filter((region) =>
|
|
203
203
|
PcbScene3dBoardOutlineRefiner.#isBoardRegionCandidate(region)
|
|
204
204
|
)
|
|
205
|
-
.map((region) =>
|
|
206
|
-
|
|
205
|
+
.map((region) => ({
|
|
206
|
+
region,
|
|
207
|
+
outline: PcbScene3dBoardOutlineRefiner.#buildOutlineFromPoints(
|
|
207
208
|
region.points
|
|
208
209
|
)
|
|
209
|
-
)
|
|
210
|
-
.filter(
|
|
211
|
-
.filter((
|
|
210
|
+
}))
|
|
211
|
+
.filter((candidate) => candidate.outline)
|
|
212
|
+
.filter((candidate) =>
|
|
212
213
|
PcbScene3dBoardOutlineRefiner.#boundsAreCompatible(
|
|
213
214
|
currentBounds,
|
|
214
|
-
outline
|
|
215
|
+
candidate.outline
|
|
215
216
|
)
|
|
216
217
|
)
|
|
217
|
-
.filter(
|
|
218
|
+
.filter(
|
|
219
|
+
(candidate) =>
|
|
220
|
+
!PcbScene3dBoardOutlineRefiner.#isInsetCutoutCandidate(
|
|
221
|
+
currentBounds,
|
|
222
|
+
candidate.outline,
|
|
223
|
+
candidate.region
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
.filter((candidate) =>
|
|
218
227
|
PcbScene3dBoardOutlineRefiner.#areaIsCompatible(
|
|
219
228
|
currentArea,
|
|
220
|
-
outline
|
|
229
|
+
candidate.outline
|
|
221
230
|
)
|
|
222
231
|
)
|
|
223
|
-
.map((
|
|
224
|
-
outline,
|
|
232
|
+
.map((candidate) => ({
|
|
233
|
+
outline: candidate.outline,
|
|
225
234
|
score: PcbScene3dBoardOutlineRefiner.#scoreBounds(
|
|
226
235
|
currentBounds,
|
|
227
|
-
outline
|
|
236
|
+
candidate.outline
|
|
228
237
|
)
|
|
229
238
|
}))
|
|
230
239
|
.sort((left, right) => left.score - right.score)
|
|
@@ -232,6 +241,40 @@ export class PcbScene3dBoardOutlineRefiner {
|
|
|
232
241
|
return candidates[0]?.outline || null
|
|
233
242
|
}
|
|
234
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Returns true when an explicit board cutout is fully inset from the
|
|
246
|
+
* current board envelope and therefore cannot represent the outer edge.
|
|
247
|
+
* @param {object} current Current outline bounds.
|
|
248
|
+
* @param {object} candidate Candidate outline.
|
|
249
|
+
* @param {object} region Source board region.
|
|
250
|
+
* @returns {boolean}
|
|
251
|
+
*/
|
|
252
|
+
static #isInsetCutoutCandidate(current, candidate, region) {
|
|
253
|
+
if (region?.isBoardCutout !== true) {
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const candidateBounds =
|
|
258
|
+
PcbScene3dBoardOutlineRefiner.#resolveOutlineBounds(candidate)
|
|
259
|
+
if (!candidateBounds) {
|
|
260
|
+
return false
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const epsilon = PcbScene3dBoardOutlineRefiner.#POINT_EPSILON_MIL
|
|
264
|
+
const insideEnvelope =
|
|
265
|
+
candidateBounds.minX >= current.minX - epsilon &&
|
|
266
|
+
candidateBounds.minY >= current.minY - epsilon &&
|
|
267
|
+
candidateBounds.maxX <= current.maxX + epsilon &&
|
|
268
|
+
candidateBounds.maxY <= current.maxY + epsilon
|
|
269
|
+
const touchesOuterEdge =
|
|
270
|
+
Math.abs(candidateBounds.minX - current.minX) <= epsilon ||
|
|
271
|
+
Math.abs(candidateBounds.minY - current.minY) <= epsilon ||
|
|
272
|
+
Math.abs(candidateBounds.maxX - current.maxX) <= epsilon ||
|
|
273
|
+
Math.abs(candidateBounds.maxY - current.maxY) <= epsilon
|
|
274
|
+
|
|
275
|
+
return insideEnvelope && !touchesOuterEdge
|
|
276
|
+
}
|
|
277
|
+
|
|
235
278
|
/**
|
|
236
279
|
* Returns true when a region can represent the board body boundary.
|
|
237
280
|
* @param {object} region Source board region.
|