altium-toolkit 1.1.36 → 1.1.37
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 +15 -0
- package/package.json +1 -1
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
- package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
- package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
- package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +766 -19
- package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
- package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +180 -158
- package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
- package/src/ui/PcbScene3dBuilder.mjs +613 -117
- package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
- package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +87 -2
- package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +32 -16
- package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +1 -0
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
|
|
5
|
+
const MILS_PER_METER = 39370.07874015748
|
|
6
|
+
const MILS_PER_MILLIMETER = 1000 / 25.4
|
|
7
|
+
const MILS_PER_INCH = 1000
|
|
8
|
+
|
|
5
9
|
/**
|
|
6
10
|
* Indexes session companion assets for 3D model lookup.
|
|
7
11
|
*/
|
|
@@ -9,12 +13,12 @@ export class PcbScene3dModelRegistry {
|
|
|
9
13
|
/** @type {{ file?: File | Blob | null, name: string, relativePath: string, format: string, source: string, normalizedPath: string, normalizedBaseName: string }[]} */
|
|
10
14
|
#modelFiles
|
|
11
15
|
|
|
12
|
-
/** @type {{ id: string, checksum: number | null, name: string, format: string, payloadText: string, sourceStream: string, normalizedId: string, normalizedBaseName: string }[]} */
|
|
16
|
+
/** @type {{ id: string, checksum: number | null, name: string, format: string, payloadText: string, sourceStream: string, normalizedId: string, normalizedBaseName: string, boundsMil?: { width: number, depth: number, height: number }, transform?: { rotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number } }[]} */
|
|
13
17
|
#embeddedModels
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* @param {{ file?: File | Blob | null, name: string, relativePath: string, format: string, source: string, normalizedPath: string, normalizedBaseName: string }[]} modelFiles
|
|
17
|
-
* @param {{ id: string, checksum: number | null, name: string, format: string, payloadText: string, sourceStream: string, normalizedId: string, normalizedBaseName: string }[]} embeddedModels
|
|
21
|
+
* @param {{ id: string, checksum: number | null, name: string, format: string, payloadText: string, sourceStream: string, normalizedId: string, normalizedBaseName: string, boundsMil?: { width: number, depth: number, height: number }, transform?: { rotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number } }[]} embeddedModels
|
|
18
22
|
*/
|
|
19
23
|
constructor(modelFiles, embeddedModels) {
|
|
20
24
|
this.#modelFiles = modelFiles
|
|
@@ -24,7 +28,7 @@ export class PcbScene3dModelRegistry {
|
|
|
24
28
|
/**
|
|
25
29
|
* Creates one model registry from session files.
|
|
26
30
|
* @param {{ name?: string, relativePath?: string, source?: string }[]} sessionFiles
|
|
27
|
-
* @param {{ id?: string, checksum?: number | null, name?: string, format?: string, payloadText?: string, sourceStream?: string }[]} [embeddedModels]
|
|
31
|
+
* @param {{ id?: string, checksum?: number | null, name?: string, format?: string, payloadText?: string, sourceStream?: string, transform?: object }[]} [embeddedModels]
|
|
28
32
|
* @returns {PcbScene3dModelRegistry}
|
|
29
33
|
*/
|
|
30
34
|
static create(sessionFiles, embeddedModels = []) {
|
|
@@ -223,11 +227,16 @@ export class PcbScene3dModelRegistry {
|
|
|
223
227
|
* Resolves the best available model for one normalized component-body
|
|
224
228
|
* placement.
|
|
225
229
|
* @param {{ modelId?: string, checksum?: number | null, name?: string }} componentBody
|
|
226
|
-
* @returns {{ origin: 'embedded' | 'session', file?: File | Blob | null, name: string, relativePath?: string, format: string, payloadText?: string, sourceStream?: string } | null}
|
|
230
|
+
* @returns {{ origin: 'embedded' | 'session', file?: File | Blob | null, name: string, relativePath?: string, format: string, payloadText?: string, sourceStream?: string, boundsMil?: { width: number, depth: number, height: number }, transform?: { rotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number } } | null}
|
|
227
231
|
*/
|
|
228
232
|
resolveComponentBodyModel(componentBody) {
|
|
229
233
|
const embeddedMatch = this.#resolveEmbeddedMatch(componentBody)
|
|
230
|
-
if (
|
|
234
|
+
if (
|
|
235
|
+
embeddedMatch &&
|
|
236
|
+
PcbScene3dModelRegistry.#isRenderableEmbeddedFormat(
|
|
237
|
+
embeddedMatch.format
|
|
238
|
+
)
|
|
239
|
+
) {
|
|
231
240
|
return embeddedMatch
|
|
232
241
|
}
|
|
233
242
|
|
|
@@ -279,8 +288,8 @@ export class PcbScene3dModelRegistry {
|
|
|
279
288
|
|
|
280
289
|
/**
|
|
281
290
|
* Normalizes one embedded payload for registry lookup.
|
|
282
|
-
* @param {{ id?: string, checksum?: number | null, name?: string, format?: string, payloadText?: string, sourceStream?: string }} model
|
|
283
|
-
* @returns {{ id: string, checksum: number | null, name: string, format: string, payloadText: string, sourceStream: string, normalizedId: string, normalizedBaseName: string } | null}
|
|
291
|
+
* @param {{ id?: string, checksum?: number | null, name?: string, format?: string, payloadText?: string, sourceStream?: string, transform?: object }} model
|
|
292
|
+
* @returns {{ id: string, checksum: number | null, name: string, format: string, payloadText: string, sourceStream: string, normalizedId: string, normalizedBaseName: string, boundsMil?: { width: number, depth: number, height: number }, transform?: { rotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number } } | null}
|
|
284
293
|
*/
|
|
285
294
|
static #normalizeEmbeddedModel(model) {
|
|
286
295
|
const id = String(model?.id || '').trim()
|
|
@@ -293,6 +302,14 @@ export class PcbScene3dModelRegistry {
|
|
|
293
302
|
return null
|
|
294
303
|
}
|
|
295
304
|
|
|
305
|
+
const boundsMil = PcbScene3dModelRegistry.#resolveEmbeddedBoundsMil(
|
|
306
|
+
format,
|
|
307
|
+
payloadText
|
|
308
|
+
)
|
|
309
|
+
const transform = PcbScene3dModelRegistry.#normalizeModelTransform(
|
|
310
|
+
model?.transform
|
|
311
|
+
)
|
|
312
|
+
|
|
296
313
|
return {
|
|
297
314
|
id,
|
|
298
315
|
checksum: Number.isFinite(Number(model?.checksum))
|
|
@@ -305,14 +322,16 @@ export class PcbScene3dModelRegistry {
|
|
|
305
322
|
normalizedId: PcbScene3dModelRegistry.#normalizeToken(id),
|
|
306
323
|
normalizedBaseName: PcbScene3dModelRegistry.#normalizeToken(
|
|
307
324
|
name.replace(/\.[^.]+$/, '')
|
|
308
|
-
)
|
|
325
|
+
),
|
|
326
|
+
...(boundsMil ? { boundsMil } : {}),
|
|
327
|
+
...(transform ? { transform } : {})
|
|
309
328
|
}
|
|
310
329
|
}
|
|
311
330
|
|
|
312
331
|
/**
|
|
313
332
|
* Resolves one embedded model match from authored model metadata.
|
|
314
333
|
* @param {{ modelId?: string, checksum?: number | null, name?: string }} componentBody
|
|
315
|
-
* @returns {{ origin: 'embedded', name: string, format: string, payloadText: string, sourceStream: string } | null}
|
|
334
|
+
* @returns {{ origin: 'embedded', name: string, format: string, payloadText: string, sourceStream: string, boundsMil?: { width: number, depth: number, height: number }, transform?: { rotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number } } | null}
|
|
316
335
|
*/
|
|
317
336
|
#resolveEmbeddedMatch(componentBody) {
|
|
318
337
|
const normalizedId = PcbScene3dModelRegistry.#normalizeToken(
|
|
@@ -351,7 +370,189 @@ export class PcbScene3dModelRegistry {
|
|
|
351
370
|
name: embeddedMatch.name,
|
|
352
371
|
format: embeddedMatch.format,
|
|
353
372
|
payloadText: embeddedMatch.payloadText,
|
|
354
|
-
sourceStream: embeddedMatch.sourceStream
|
|
373
|
+
sourceStream: embeddedMatch.sourceStream,
|
|
374
|
+
...(embeddedMatch.boundsMil
|
|
375
|
+
? { boundsMil: embeddedMatch.boundsMil }
|
|
376
|
+
: {}),
|
|
377
|
+
...(embeddedMatch.transform
|
|
378
|
+
? { transform: embeddedMatch.transform }
|
|
379
|
+
: {})
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Normalizes optional embedded model transform metadata.
|
|
385
|
+
* @param {object | null | undefined} transform Source transform metadata.
|
|
386
|
+
* @returns {{ rotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number } | null}
|
|
387
|
+
*/
|
|
388
|
+
static #normalizeModelTransform(transform) {
|
|
389
|
+
if (!transform || typeof transform !== 'object') {
|
|
390
|
+
return null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const normalized = {}
|
|
394
|
+
const sourceRotation = transform.rotationDeg || {}
|
|
395
|
+
const rotationDeg = {}
|
|
396
|
+
for (const axis of ['x', 'y', 'z']) {
|
|
397
|
+
const value = Number(sourceRotation?.[axis])
|
|
398
|
+
if (Number.isFinite(value)) {
|
|
399
|
+
rotationDeg[axis] = value
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (Object.keys(rotationDeg).length) {
|
|
403
|
+
normalized.rotationDeg = rotationDeg
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const dzMil = Number(transform.dzMil)
|
|
407
|
+
if (Number.isFinite(dzMil)) {
|
|
408
|
+
normalized.dzMil = dzMil
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return Object.keys(normalized).length ? normalized : null
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Resolves an embedded model envelope in mils when the payload format is
|
|
416
|
+
* inspectable without a geometry engine.
|
|
417
|
+
* @param {string} format Embedded model format.
|
|
418
|
+
* @param {string} payloadText Embedded model text payload.
|
|
419
|
+
* @returns {{ width: number, depth: number, height: number } | null}
|
|
420
|
+
*/
|
|
421
|
+
static #resolveEmbeddedBoundsMil(format, payloadText) {
|
|
422
|
+
const normalizedFormat = String(format || '').toLowerCase()
|
|
423
|
+
if (normalizedFormat !== 'step' && normalizedFormat !== 'stp') {
|
|
424
|
+
return null
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return PcbScene3dModelRegistry.#resolveStepBoundsMil(payloadText)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Resolves a STEP payload envelope from authored Cartesian points.
|
|
432
|
+
* @param {string} payloadText STEP text payload.
|
|
433
|
+
* @returns {{ width: number, depth: number, height: number } | null}
|
|
434
|
+
*/
|
|
435
|
+
static #resolveStepBoundsMil(payloadText) {
|
|
436
|
+
const text = String(payloadText || '')
|
|
437
|
+
const points = []
|
|
438
|
+
const pointPattern =
|
|
439
|
+
/CARTESIAN_POINT\s*\(\s*(?:'[^']*'|[^,]*),\s*\(([^)]*)\)\s*\)/giu
|
|
440
|
+
let match = pointPattern.exec(text)
|
|
441
|
+
|
|
442
|
+
while (match) {
|
|
443
|
+
const coordinates = String(match[1] || '')
|
|
444
|
+
.split(',')
|
|
445
|
+
.slice(0, 3)
|
|
446
|
+
.map((value) => Number(value.trim()))
|
|
447
|
+
if (
|
|
448
|
+
coordinates.length === 3 &&
|
|
449
|
+
coordinates.every((value) => Number.isFinite(value))
|
|
450
|
+
) {
|
|
451
|
+
points.push(coordinates)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
match = pointPattern.exec(text)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (points.length < 2) {
|
|
458
|
+
return null
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const scale = PcbScene3dModelRegistry.#resolveStepMilScale(text)
|
|
462
|
+
const [firstPoint] = points
|
|
463
|
+
const bounds = {
|
|
464
|
+
minX: firstPoint[0],
|
|
465
|
+
maxX: firstPoint[0],
|
|
466
|
+
minY: firstPoint[1],
|
|
467
|
+
maxY: firstPoint[1],
|
|
468
|
+
minZ: firstPoint[2],
|
|
469
|
+
maxZ: firstPoint[2]
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
points.slice(1).forEach(([x, y, z]) => {
|
|
473
|
+
bounds.minX = Math.min(bounds.minX, x)
|
|
474
|
+
bounds.maxX = Math.max(bounds.maxX, x)
|
|
475
|
+
bounds.minY = Math.min(bounds.minY, y)
|
|
476
|
+
bounds.maxY = Math.max(bounds.maxY, y)
|
|
477
|
+
bounds.minZ = Math.min(bounds.minZ, z)
|
|
478
|
+
bounds.maxZ = Math.max(bounds.maxZ, z)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
width: (bounds.maxX - bounds.minX) * scale,
|
|
483
|
+
depth: (bounds.maxY - bounds.minY) * scale,
|
|
484
|
+
height: (bounds.maxZ - bounds.minZ) * scale
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Resolves the STEP length-unit scale to mils.
|
|
490
|
+
* @param {string} payloadText STEP text payload.
|
|
491
|
+
* @returns {number}
|
|
492
|
+
*/
|
|
493
|
+
static #resolveStepMilScale(payloadText) {
|
|
494
|
+
const text = String(payloadText || '').toUpperCase()
|
|
495
|
+
if (/\bINCH\b|\.INCH\./u.test(text)) {
|
|
496
|
+
return MILS_PER_INCH
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const siUnitMatch = text.match(
|
|
500
|
+
/SI_UNIT\s*\(\s*(\.[A-Z]+\.|\$)\s*,\s*\.METRE\.\s*\)/u
|
|
501
|
+
)
|
|
502
|
+
if (siUnitMatch) {
|
|
503
|
+
return (
|
|
504
|
+
PcbScene3dModelRegistry.#metricPrefixMeterScale(
|
|
505
|
+
siUnitMatch[1]
|
|
506
|
+
) * MILS_PER_METER
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return MILS_PER_MILLIMETER
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Resolves one SI metric prefix into metres per STEP coordinate unit.
|
|
515
|
+
* @param {string} prefix STEP SI prefix token.
|
|
516
|
+
* @returns {number}
|
|
517
|
+
*/
|
|
518
|
+
static #metricPrefixMeterScale(prefix) {
|
|
519
|
+
switch (String(prefix || '').toUpperCase()) {
|
|
520
|
+
case '.EXA.':
|
|
521
|
+
return 1e18
|
|
522
|
+
case '.PETA.':
|
|
523
|
+
return 1e15
|
|
524
|
+
case '.TERA.':
|
|
525
|
+
return 1e12
|
|
526
|
+
case '.GIGA.':
|
|
527
|
+
return 1e9
|
|
528
|
+
case '.MEGA.':
|
|
529
|
+
return 1e6
|
|
530
|
+
case '.KILO.':
|
|
531
|
+
return 1e3
|
|
532
|
+
case '.HECTO.':
|
|
533
|
+
return 1e2
|
|
534
|
+
case '.DECA.':
|
|
535
|
+
return 1e1
|
|
536
|
+
case '$':
|
|
537
|
+
return 1
|
|
538
|
+
case '.DECI.':
|
|
539
|
+
return 1e-1
|
|
540
|
+
case '.CENTI.':
|
|
541
|
+
return 1e-2
|
|
542
|
+
case '.MILLI.':
|
|
543
|
+
return 1e-3
|
|
544
|
+
case '.MICRO.':
|
|
545
|
+
return 1e-6
|
|
546
|
+
case '.NANO.':
|
|
547
|
+
return 1e-9
|
|
548
|
+
case '.PICO.':
|
|
549
|
+
return 1e-12
|
|
550
|
+
case '.FEMTO.':
|
|
551
|
+
return 1e-15
|
|
552
|
+
case '.ATTO.':
|
|
553
|
+
return 1e-18
|
|
554
|
+
default:
|
|
555
|
+
return 1e-3
|
|
355
556
|
}
|
|
356
557
|
}
|
|
357
558
|
|
|
@@ -364,6 +565,17 @@ export class PcbScene3dModelRegistry {
|
|
|
364
565
|
return format === 'wrl' ? 0 : 1
|
|
365
566
|
}
|
|
366
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Checks whether one embedded payload format can be loaded inline.
|
|
570
|
+
* @param {string} format Embedded payload format.
|
|
571
|
+
* @returns {boolean}
|
|
572
|
+
*/
|
|
573
|
+
static #isRenderableEmbeddedFormat(format) {
|
|
574
|
+
return ['step', 'stp', 'wrl'].includes(
|
|
575
|
+
String(format || '').toLowerCase()
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
367
579
|
/**
|
|
368
580
|
* Checks whether one model path is in a conventional board model folder.
|
|
369
581
|
* @param {string | undefined} relativePath
|
|
@@ -9,10 +9,21 @@ export class PcbScene3dPlacementSideResolver {
|
|
|
9
9
|
static #NEARBY_SIDE_HINT_MAX_DISTANCE_MIL = 600
|
|
10
10
|
static #NEGATIVE_STANDOFF_SIDE_RATIO = 0.3
|
|
11
11
|
static #MIN_NEGATIVE_STANDOFF_SIDE_MIL = 20
|
|
12
|
+
static #IGNORED_IDENTITY_TOKENS = new Set([
|
|
13
|
+
'con',
|
|
14
|
+
'step',
|
|
15
|
+
'stp',
|
|
16
|
+
'model',
|
|
17
|
+
'default',
|
|
18
|
+
'black'
|
|
19
|
+
])
|
|
20
|
+
static #BODY_TOKEN_CACHE = new WeakMap()
|
|
21
|
+
static #COMPONENT_TOKEN_CACHE = new WeakMap()
|
|
22
|
+
static #AFFINITY_SCORE_CACHE = new WeakMap()
|
|
12
23
|
|
|
13
24
|
/**
|
|
14
25
|
* Resolves which board side one explicit model should mount on.
|
|
15
|
-
* @param {{ layer?: string, positionMil?: { x?: number, y?: number }, standoffHeightMil?: number | null, overallHeightMil?: number | null }} componentBody
|
|
26
|
+
* @param {{ layer?: string, positionMil?: { x?: number, y?: number }, dzMil?: number | null, standoffHeightMil?: number | null, overallHeightMil?: number | null }} componentBody
|
|
16
27
|
* @param {{ layer?: string } | null} matchedComponent
|
|
17
28
|
* @param {{ layer?: string, pattern?: string, source?: string, modelPath?: string, x?: number, y?: number }[]} components
|
|
18
29
|
* @param {{ minX?: number, minY?: number, widthMil?: number, heightMil?: number } | null} board
|
|
@@ -34,14 +45,21 @@ export class PcbScene3dPlacementSideResolver {
|
|
|
34
45
|
|
|
35
46
|
const standoffSide =
|
|
36
47
|
PcbScene3dPlacementSideResolver.#resolveStandoffSide(componentBody)
|
|
48
|
+
const trustedStandoffSide =
|
|
49
|
+
PcbScene3dPlacementSideResolver.#shouldTrustStandoffSide(
|
|
50
|
+
componentBody,
|
|
51
|
+
standoffSide
|
|
52
|
+
)
|
|
53
|
+
? standoffSide
|
|
54
|
+
: null
|
|
37
55
|
if (
|
|
38
|
-
|
|
56
|
+
trustedStandoffSide &&
|
|
39
57
|
PcbScene3dPlacementSideResolver.#isBodyAnchorInsideBoard(
|
|
40
58
|
componentBody,
|
|
41
59
|
board
|
|
42
60
|
)
|
|
43
61
|
) {
|
|
44
|
-
return
|
|
62
|
+
return trustedStandoffSide
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
const nearbySide =
|
|
@@ -61,14 +79,14 @@ export class PcbScene3dPlacementSideResolver {
|
|
|
61
79
|
return mechanicalSide
|
|
62
80
|
}
|
|
63
81
|
|
|
64
|
-
return
|
|
82
|
+
return trustedStandoffSide || 'top'
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
/**
|
|
68
86
|
* Resolves which board side one authored static shape body should mount on.
|
|
69
87
|
* Shape bodies carry explicit mechanical-layer intent, so that side wins
|
|
70
88
|
* over loose nearby-package identity unless the body was directly matched.
|
|
71
|
-
* @param {{ layer?: string, positionMil?: { x?: number, y?: number }, standoffHeightMil?: number | null, overallHeightMil?: number | null }} componentBody
|
|
89
|
+
* @param {{ layer?: string, positionMil?: { x?: number, y?: number }, dzMil?: number | null, standoffHeightMil?: number | null, overallHeightMil?: number | null }} componentBody
|
|
72
90
|
* @param {{ layer?: string } | null} matchedComponent
|
|
73
91
|
* @param {{ layer?: string, pattern?: string, source?: string, modelPath?: string, x?: number, y?: number }[]} components
|
|
74
92
|
* @param {{ minX?: number, minY?: number, widthMil?: number, heightMil?: number } | null} board
|
|
@@ -90,14 +108,21 @@ export class PcbScene3dPlacementSideResolver {
|
|
|
90
108
|
|
|
91
109
|
const standoffSide =
|
|
92
110
|
PcbScene3dPlacementSideResolver.#resolveStandoffSide(componentBody)
|
|
111
|
+
const trustedStandoffSide =
|
|
112
|
+
PcbScene3dPlacementSideResolver.#shouldTrustStandoffSide(
|
|
113
|
+
componentBody,
|
|
114
|
+
standoffSide
|
|
115
|
+
)
|
|
116
|
+
? standoffSide
|
|
117
|
+
: null
|
|
93
118
|
if (
|
|
94
|
-
|
|
119
|
+
trustedStandoffSide &&
|
|
95
120
|
PcbScene3dPlacementSideResolver.#isBodyAnchorInsideBoard(
|
|
96
121
|
componentBody,
|
|
97
122
|
board
|
|
98
123
|
)
|
|
99
124
|
) {
|
|
100
|
-
return
|
|
125
|
+
return trustedStandoffSide
|
|
101
126
|
}
|
|
102
127
|
|
|
103
128
|
const mechanicalSide =
|
|
@@ -117,7 +142,7 @@ export class PcbScene3dPlacementSideResolver {
|
|
|
117
142
|
return nearbySide
|
|
118
143
|
}
|
|
119
144
|
|
|
120
|
-
return
|
|
145
|
+
return trustedStandoffSide || 'top'
|
|
121
146
|
}
|
|
122
147
|
|
|
123
148
|
/**
|
|
@@ -141,21 +166,39 @@ export class PcbScene3dPlacementSideResolver {
|
|
|
141
166
|
* @returns {number}
|
|
142
167
|
*/
|
|
143
168
|
static scoreBodyComponentAffinity(componentBody, component) {
|
|
169
|
+
const bodyValues =
|
|
170
|
+
PcbScene3dPlacementSideResolver.#bodyAffinityValues(componentBody)
|
|
171
|
+
const componentValues =
|
|
172
|
+
PcbScene3dPlacementSideResolver.#componentAffinityValues(component)
|
|
173
|
+
const bodyKey =
|
|
174
|
+
PcbScene3dPlacementSideResolver.#cacheIdentityKey(bodyValues)
|
|
175
|
+
const componentKey =
|
|
176
|
+
PcbScene3dPlacementSideResolver.#cacheIdentityKey(componentValues)
|
|
177
|
+
const cachedScore =
|
|
178
|
+
PcbScene3dPlacementSideResolver.#cachedAffinityScore(
|
|
179
|
+
componentBody,
|
|
180
|
+
component,
|
|
181
|
+
bodyKey,
|
|
182
|
+
componentKey
|
|
183
|
+
)
|
|
184
|
+
if (cachedScore !== null) {
|
|
185
|
+
return cachedScore
|
|
186
|
+
}
|
|
187
|
+
|
|
144
188
|
const bodyTokens =
|
|
145
|
-
PcbScene3dPlacementSideResolver.#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
189
|
+
PcbScene3dPlacementSideResolver.#cachedMeaningfulTokens(
|
|
190
|
+
PcbScene3dPlacementSideResolver.#BODY_TOKEN_CACHE,
|
|
191
|
+
componentBody,
|
|
192
|
+
bodyKey,
|
|
193
|
+
bodyValues
|
|
194
|
+
)
|
|
149
195
|
const componentTokens =
|
|
150
|
-
PcbScene3dPlacementSideResolver.#
|
|
151
|
-
|
|
152
|
-
component
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
component
|
|
157
|
-
)
|
|
158
|
-
])
|
|
196
|
+
PcbScene3dPlacementSideResolver.#cachedMeaningfulTokens(
|
|
197
|
+
PcbScene3dPlacementSideResolver.#COMPONENT_TOKEN_CACHE,
|
|
198
|
+
component,
|
|
199
|
+
componentKey,
|
|
200
|
+
componentValues
|
|
201
|
+
)
|
|
159
202
|
let score = 0
|
|
160
203
|
|
|
161
204
|
bodyTokens.forEach((token) => {
|
|
@@ -164,9 +207,166 @@ export class PcbScene3dPlacementSideResolver {
|
|
|
164
207
|
}
|
|
165
208
|
})
|
|
166
209
|
|
|
210
|
+
PcbScene3dPlacementSideResolver.#cacheAffinityScore(
|
|
211
|
+
componentBody,
|
|
212
|
+
component,
|
|
213
|
+
bodyKey,
|
|
214
|
+
componentKey,
|
|
215
|
+
score
|
|
216
|
+
)
|
|
217
|
+
|
|
167
218
|
return score
|
|
168
219
|
}
|
|
169
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Resolves body identity fields used for affinity scoring.
|
|
223
|
+
* @param {{ name?: string, identifier?: string } | null | undefined} componentBody Component-body record.
|
|
224
|
+
* @returns {(string | undefined)[]}
|
|
225
|
+
*/
|
|
226
|
+
static #bodyAffinityValues(componentBody) {
|
|
227
|
+
return [
|
|
228
|
+
componentBody?.identifier,
|
|
229
|
+
String(componentBody?.name || '').replace(/\.[^.]+$/, '')
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolves component identity fields used for affinity scoring.
|
|
235
|
+
* @param {{ pattern?: string, source?: string, modelPath?: string, description?: string, parameters?: object, provenance?: object } | null | undefined} component Component record.
|
|
236
|
+
* @returns {(string | undefined)[]}
|
|
237
|
+
*/
|
|
238
|
+
static #componentAffinityValues(component) {
|
|
239
|
+
return [
|
|
240
|
+
component?.pattern,
|
|
241
|
+
component?.source,
|
|
242
|
+
component?.modelPath,
|
|
243
|
+
component?.description,
|
|
244
|
+
...PcbScene3dPlacementSideResolver.#componentPackageMetadata(
|
|
245
|
+
component
|
|
246
|
+
)
|
|
247
|
+
]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolves a deterministic key for identity fields.
|
|
252
|
+
* @param {unknown[]} values Identity field values.
|
|
253
|
+
* @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
static #cacheIdentityKey(values) {
|
|
256
|
+
return (Array.isArray(values) ? values : [])
|
|
257
|
+
.map((value) => String(value || ''))
|
|
258
|
+
.join('\u0000')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Returns a cached score when both input identity keys still match.
|
|
263
|
+
* @param {unknown} componentBody Component-body record.
|
|
264
|
+
* @param {unknown} component Component record.
|
|
265
|
+
* @param {string} bodyKey Current body identity key.
|
|
266
|
+
* @param {string} componentKey Current component identity key.
|
|
267
|
+
* @returns {number | null}
|
|
268
|
+
*/
|
|
269
|
+
static #cachedAffinityScore(
|
|
270
|
+
componentBody,
|
|
271
|
+
component,
|
|
272
|
+
bodyKey,
|
|
273
|
+
componentKey
|
|
274
|
+
) {
|
|
275
|
+
if (
|
|
276
|
+
!PcbScene3dPlacementSideResolver.#isObjectLike(componentBody) ||
|
|
277
|
+
!PcbScene3dPlacementSideResolver.#isObjectLike(component)
|
|
278
|
+
) {
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const componentScores =
|
|
283
|
+
PcbScene3dPlacementSideResolver.#AFFINITY_SCORE_CACHE.get(
|
|
284
|
+
componentBody
|
|
285
|
+
)
|
|
286
|
+
const cachedScore = componentScores?.get(component)
|
|
287
|
+
|
|
288
|
+
return cachedScore?.bodyKey === bodyKey &&
|
|
289
|
+
cachedScore?.componentKey === componentKey
|
|
290
|
+
? cachedScore.score
|
|
291
|
+
: null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Caches one body/component affinity score.
|
|
296
|
+
* @param {unknown} componentBody Component-body record.
|
|
297
|
+
* @param {unknown} component Component record.
|
|
298
|
+
* @param {string} bodyKey Current body identity key.
|
|
299
|
+
* @param {string} componentKey Current component identity key.
|
|
300
|
+
* @param {number} score Affinity score.
|
|
301
|
+
* @returns {void}
|
|
302
|
+
*/
|
|
303
|
+
static #cacheAffinityScore(
|
|
304
|
+
componentBody,
|
|
305
|
+
component,
|
|
306
|
+
bodyKey,
|
|
307
|
+
componentKey,
|
|
308
|
+
score
|
|
309
|
+
) {
|
|
310
|
+
if (
|
|
311
|
+
!PcbScene3dPlacementSideResolver.#isObjectLike(componentBody) ||
|
|
312
|
+
!PcbScene3dPlacementSideResolver.#isObjectLike(component)
|
|
313
|
+
) {
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let componentScores =
|
|
318
|
+
PcbScene3dPlacementSideResolver.#AFFINITY_SCORE_CACHE.get(
|
|
319
|
+
componentBody
|
|
320
|
+
)
|
|
321
|
+
if (!componentScores) {
|
|
322
|
+
componentScores = new WeakMap()
|
|
323
|
+
PcbScene3dPlacementSideResolver.#AFFINITY_SCORE_CACHE.set(
|
|
324
|
+
componentBody,
|
|
325
|
+
componentScores
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
componentScores.set(component, { bodyKey, componentKey, score })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Collects cached normalized identity tokens.
|
|
334
|
+
* @param {WeakMap<object, { key: string, tokens: Set<string> }>} cache Token cache.
|
|
335
|
+
* @param {unknown} owner Source object that owns the identity fields.
|
|
336
|
+
* @param {string} key Current identity key.
|
|
337
|
+
* @param {unknown[]} values Identity field values.
|
|
338
|
+
* @returns {Set<string>}
|
|
339
|
+
*/
|
|
340
|
+
static #cachedMeaningfulTokens(cache, owner, key, values) {
|
|
341
|
+
if (!PcbScene3dPlacementSideResolver.#isObjectLike(owner)) {
|
|
342
|
+
return PcbScene3dPlacementSideResolver.#collectMeaningfulTokens(
|
|
343
|
+
values
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const cached = cache.get(owner)
|
|
348
|
+
if (cached?.key === key) {
|
|
349
|
+
return cached.tokens
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const tokens =
|
|
353
|
+
PcbScene3dPlacementSideResolver.#collectMeaningfulTokens(values)
|
|
354
|
+
cache.set(owner, { key, tokens })
|
|
355
|
+
return tokens
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Returns true when a value can be used as a WeakMap key.
|
|
360
|
+
* @param {unknown} value Candidate value.
|
|
361
|
+
* @returns {boolean}
|
|
362
|
+
*/
|
|
363
|
+
static #isObjectLike(value) {
|
|
364
|
+
return (
|
|
365
|
+
(typeof value === 'object' && value !== null) ||
|
|
366
|
+
typeof value === 'function'
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
170
370
|
/**
|
|
171
371
|
* Resolves package-related component metadata that can identify generic
|
|
172
372
|
* embedded 3D bodies when pattern/source only name the electrical part.
|
|
@@ -248,6 +448,46 @@ export class PcbScene3dPlacementSideResolver {
|
|
|
248
448
|
return Math.abs(standoff) >= threshold ? 'bottom' : null
|
|
249
449
|
}
|
|
250
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Checks whether negative standoff metadata is reliable enough to infer
|
|
453
|
+
* the board side before nearby package or mechanical-layer evidence.
|
|
454
|
+
* @param {{ dzMil?: number | null, standoffHeightMil?: number | null, overallHeightMil?: number | null } | null} componentBody Component body row.
|
|
455
|
+
* @param {'bottom' | null} standoffSide Side inferred from the standoff.
|
|
456
|
+
* @returns {boolean}
|
|
457
|
+
*/
|
|
458
|
+
static #shouldTrustStandoffSide(componentBody, standoffSide) {
|
|
459
|
+
if (!standoffSide) {
|
|
460
|
+
return false
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return !PcbScene3dPlacementSideResolver.#hasInEnvelopeDzAgainstOverlargeStandoff(
|
|
464
|
+
componentBody
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Returns true for source-origin artifacts where Altium's standoff exceeds
|
|
470
|
+
* the model envelope but the authored dz offset is still physically valid.
|
|
471
|
+
* @param {{ dzMil?: number | null, standoffHeightMil?: number | null, overallHeightMil?: number | null } | null} componentBody Component body row.
|
|
472
|
+
* @returns {boolean}
|
|
473
|
+
*/
|
|
474
|
+
static #hasInEnvelopeDzAgainstOverlargeStandoff(componentBody) {
|
|
475
|
+
const standoff = Number(componentBody?.standoffHeightMil)
|
|
476
|
+
const dz = Number(componentBody?.dzMil)
|
|
477
|
+
const overallHeight = Number(componentBody?.overallHeightMil)
|
|
478
|
+
|
|
479
|
+
return (
|
|
480
|
+
Number.isFinite(standoff) &&
|
|
481
|
+
standoff < 0 &&
|
|
482
|
+
Number.isFinite(dz) &&
|
|
483
|
+
dz < 0 &&
|
|
484
|
+
Number.isFinite(overallHeight) &&
|
|
485
|
+
overallHeight > 0 &&
|
|
486
|
+
Math.abs(standoff) > overallHeight &&
|
|
487
|
+
Math.abs(dz) < overallHeight
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
251
491
|
/**
|
|
252
492
|
* Returns true when one body anchor sits inside the normalized board bounds.
|
|
253
493
|
* @param {{ positionMil?: { x?: number, y?: number } } | null} componentBody
|
|
@@ -383,7 +623,7 @@ export class PcbScene3dPlacementSideResolver {
|
|
|
383
623
|
static #isMeaningfulToken(token) {
|
|
384
624
|
return (
|
|
385
625
|
String(token || '').length >= 2 &&
|
|
386
|
-
!
|
|
626
|
+
!PcbScene3dPlacementSideResolver.#IGNORED_IDENTITY_TOKENS.has(
|
|
387
627
|
String(token || '')
|
|
388
628
|
)
|
|
389
629
|
)
|