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.
@@ -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 (embeddedMatch) {
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
- standoffSide &&
56
+ trustedStandoffSide &&
39
57
  PcbScene3dPlacementSideResolver.#isBodyAnchorInsideBoard(
40
58
  componentBody,
41
59
  board
42
60
  )
43
61
  ) {
44
- return standoffSide
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 standoffSide || 'top'
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
- standoffSide &&
119
+ trustedStandoffSide &&
95
120
  PcbScene3dPlacementSideResolver.#isBodyAnchorInsideBoard(
96
121
  componentBody,
97
122
  board
98
123
  )
99
124
  ) {
100
- return standoffSide
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 standoffSide || 'top'
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.#collectMeaningfulTokens([
146
- componentBody?.identifier,
147
- String(componentBody?.name || '').replace(/\.[^.]+$/, '')
148
- ])
189
+ PcbScene3dPlacementSideResolver.#cachedMeaningfulTokens(
190
+ PcbScene3dPlacementSideResolver.#BODY_TOKEN_CACHE,
191
+ componentBody,
192
+ bodyKey,
193
+ bodyValues
194
+ )
149
195
  const componentTokens =
150
- PcbScene3dPlacementSideResolver.#collectMeaningfulTokens([
151
- component?.pattern,
152
- component?.source,
153
- component?.modelPath,
154
- component?.description,
155
- ...PcbScene3dPlacementSideResolver.#componentPackageMetadata(
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
- !new Set(['con', 'step', 'stp', 'model', 'default', 'black']).has(
626
+ !PcbScene3dPlacementSideResolver.#IGNORED_IDENTITY_TOKENS.has(
387
627
  String(token || '')
388
628
  )
389
629
  )