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.
@@ -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
- PcbScene3dBoardOutlineRefiner.#buildOutlineFromPoints(
205
+ .map((region) => ({
206
+ region,
207
+ outline: PcbScene3dBoardOutlineRefiner.#buildOutlineFromPoints(
207
208
  region.points
208
209
  )
209
- )
210
- .filter(Boolean)
211
- .filter((outline) =>
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((outline) =>
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((outline) => ({
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.