altium-toolkit 1.1.32 → 1.1.33

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,703 @@
1
+ const TIMING_PACKAGE_PATTERN =
2
+ /(?:^|[^a-z0-9])(?:clock|crystal|osc|oscillator|resonator|tcxo|txco|xtal)(?:$|[^a-z0-9])/i
3
+ const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
4
+
5
+ /**
6
+ * Keeps authored shape-based sub-bodies grouped under their carrier owner.
7
+ */
8
+ export class AltiumScene3dShapeStackOwnerAdapter {
9
+ static #BASE_OWNER_TOLERANCE_MIL = 60
10
+ static #STACK_BODY_RADIUS_MIL = 220
11
+ static #HEIGHT_TOLERANCE_MIL = 0.1
12
+
13
+ /**
14
+ * Applies authored stack ownership to static and external placements.
15
+ * @param {object} sceneDescription Built scene description.
16
+ * @param {object} documentModel Source document model.
17
+ * @returns {object}
18
+ */
19
+ static apply(sceneDescription, documentModel) {
20
+ if (
21
+ String(sceneDescription?.sourceFormat || '').toLowerCase() !==
22
+ 'altium'
23
+ ) {
24
+ return sceneDescription
25
+ }
26
+
27
+ const components = Array.isArray(documentModel?.pcb?.components)
28
+ ? documentModel.pcb.components
29
+ : []
30
+ const componentBodies = Array.isArray(
31
+ documentModel?.pcb?.componentBodies
32
+ )
33
+ ? documentModel.pcb.componentBodies
34
+ : []
35
+ if (!components.length || !componentBodies.length) {
36
+ return sceneDescription
37
+ }
38
+
39
+ const assignments =
40
+ AltiumScene3dShapeStackOwnerAdapter.#carrierAssignments(
41
+ componentBodies,
42
+ components
43
+ )
44
+ if (!assignments.length) {
45
+ return sceneDescription
46
+ }
47
+
48
+ const ownerGroupKeys = new Map(
49
+ assignments.map((assignment) => [
50
+ String(assignment.owner.designator || ''),
51
+ assignment.groupKey
52
+ ])
53
+ )
54
+
55
+ return {
56
+ ...sceneDescription,
57
+ components: AltiumScene3dShapeStackOwnerAdapter.#markComponents(
58
+ sceneDescription.components,
59
+ ownerGroupKeys
60
+ ),
61
+ externalPlacements:
62
+ AltiumScene3dShapeStackOwnerAdapter.#repairExternalPlacements(
63
+ sceneDescription.externalPlacements,
64
+ componentBodies,
65
+ assignments,
66
+ sceneDescription.board
67
+ ),
68
+ staticBodyPlacements:
69
+ AltiumScene3dShapeStackOwnerAdapter.#repairStaticPlacements(
70
+ sceneDescription.staticBodyPlacements,
71
+ componentBodies,
72
+ assignments
73
+ )
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Marks stack owners so the viewer skips their generated fallback body.
79
+ * @param {object[] | undefined} components Scene components.
80
+ * @param {Map<string, string>} ownerGroupKeys Authored stack owners.
81
+ * @returns {object[]}
82
+ */
83
+ static #markComponents(components, ownerGroupKeys) {
84
+ return (Array.isArray(components) ? components : []).map((component) =>
85
+ ownerGroupKeys.has(String(component?.designator || ''))
86
+ ? {
87
+ ...component,
88
+ renderFallbackBody: false,
89
+ coLocatedVariantGroupKey: ownerGroupKeys.get(
90
+ String(component?.designator || '')
91
+ )
92
+ }
93
+ : component
94
+ )
95
+ }
96
+
97
+ /**
98
+ * Repairs external model ownership for authored stack sub-bodies.
99
+ * @param {object[] | undefined} placements Scene external placements.
100
+ * @param {object[]} componentBodies Source component-body rows.
101
+ * @param {object[]} assignments Carrier assignments.
102
+ * @param {object | undefined} board Scene board metadata.
103
+ * @returns {object[]}
104
+ */
105
+ static #repairExternalPlacements(
106
+ placements,
107
+ componentBodies,
108
+ assignments,
109
+ board
110
+ ) {
111
+ const usedComponentBodies = new Set()
112
+
113
+ return (Array.isArray(placements) ? placements : []).map(
114
+ (placement) => {
115
+ const componentBody =
116
+ AltiumScene3dShapeStackOwnerAdapter.#resolveComponentBody(
117
+ placement,
118
+ componentBodies,
119
+ usedComponentBodies
120
+ )
121
+ if (componentBody) {
122
+ usedComponentBodies.add(componentBody)
123
+ }
124
+
125
+ const assignment =
126
+ AltiumScene3dShapeStackOwnerAdapter.#assignmentForBody(
127
+ componentBody,
128
+ assignments
129
+ )
130
+ if (!assignment) {
131
+ return placement
132
+ }
133
+
134
+ return AltiumScene3dShapeStackOwnerAdapter.#withOwner(
135
+ placement,
136
+ componentBody,
137
+ assignment,
138
+ board
139
+ )
140
+ }
141
+ )
142
+ }
143
+
144
+ /**
145
+ * Repairs carrier static body ownership.
146
+ * @param {object[] | undefined} placements Scene static body placements.
147
+ * @param {object[]} componentBodies Source component-body rows.
148
+ * @param {object[]} assignments Carrier assignments.
149
+ * @returns {object[]}
150
+ */
151
+ static #repairStaticPlacements(placements, componentBodies, assignments) {
152
+ return (Array.isArray(placements) ? placements : []).map(
153
+ (placement) => {
154
+ const assignment = assignments.find((candidate) =>
155
+ AltiumScene3dShapeStackOwnerAdapter.#isSameStaticBody(
156
+ placement,
157
+ candidate.base
158
+ )
159
+ )
160
+ if (!assignment) {
161
+ return placement
162
+ }
163
+
164
+ return {
165
+ ...placement,
166
+ designator: String(assignment.owner.designator || ''),
167
+ coLocatedVariantGroupKey: assignment.groupKey
168
+ }
169
+ }
170
+ )
171
+ }
172
+
173
+ /**
174
+ * Builds carrier-base to owner-component assignments.
175
+ * @param {object[]} componentBodies Source component-body rows.
176
+ * @param {object[]} components Source components.
177
+ * @returns {{ base: object, owner: object, heightMil: number, groupKey: string }[]}
178
+ */
179
+ static #carrierAssignments(componentBodies, components) {
180
+ const bases = componentBodies.filter((body) =>
181
+ AltiumScene3dShapeStackOwnerAdapter.#isCarrierBase(body)
182
+ )
183
+ const timingComponents = components.filter((component) =>
184
+ AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(component)
185
+ )
186
+ const groups =
187
+ AltiumScene3dShapeStackOwnerAdapter.#groupCarrierBases(bases)
188
+
189
+ return groups.flatMap((group) => {
190
+ const owners = timingComponents
191
+ .filter(
192
+ (component) =>
193
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
194
+ group.anchor,
195
+ component
196
+ ) <=
197
+ AltiumScene3dShapeStackOwnerAdapter
198
+ .#BASE_OWNER_TOLERANCE_MIL
199
+ )
200
+ .sort(
201
+ (left, right) =>
202
+ AltiumScene3dShapeStackOwnerAdapter.#ownerRank(right) -
203
+ AltiumScene3dShapeStackOwnerAdapter.#ownerRank(
204
+ left
205
+ ) ||
206
+ String(left?.designator || '').localeCompare(
207
+ String(right?.designator || '')
208
+ )
209
+ )
210
+ if (!owners.length) {
211
+ return []
212
+ }
213
+ const groupKey = AltiumScene3dShapeStackOwnerAdapter.#groupKey(
214
+ group.anchor
215
+ )
216
+
217
+ return group.bases
218
+ .slice()
219
+ .sort(
220
+ (left, right) =>
221
+ Number(
222
+ left?.staticGeometry?.heightMil ??
223
+ left?.overallHeightMil ??
224
+ 0
225
+ ) -
226
+ Number(
227
+ right?.staticGeometry?.heightMil ??
228
+ right?.overallHeightMil ??
229
+ 0
230
+ )
231
+ )
232
+ .map((base, index) => ({
233
+ base,
234
+ owner: owners[Math.min(index, owners.length - 1)],
235
+ groupKey,
236
+ heightMil: Number(
237
+ base?.staticGeometry?.heightMil ??
238
+ base?.overallHeightMil ??
239
+ 0
240
+ )
241
+ }))
242
+ })
243
+ }
244
+
245
+ /**
246
+ * Groups carrier bases that share one source anchor.
247
+ * @param {object[]} bases Candidate carrier bases.
248
+ * @returns {{ anchor: { x: number, y: number }, bases: object[] }[]}
249
+ */
250
+ static #groupCarrierBases(bases) {
251
+ const groups = []
252
+
253
+ bases.forEach((base) => {
254
+ const anchor = AltiumScene3dShapeStackOwnerAdapter.#point(
255
+ base?.positionMil
256
+ )
257
+ const group = groups.find(
258
+ (candidate) =>
259
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
260
+ candidate.anchor,
261
+ anchor
262
+ ) <= 1
263
+ )
264
+ if (group) {
265
+ group.bases.push(base)
266
+ } else {
267
+ groups.push({ anchor, bases: [base] })
268
+ }
269
+ })
270
+
271
+ return groups
272
+ }
273
+
274
+ /**
275
+ * Checks whether one body row describes an authored carrier base.
276
+ * @param {object} componentBody Source component-body row.
277
+ * @returns {boolean}
278
+ */
279
+ static #isCarrierBase(componentBody) {
280
+ const geometry = componentBody?.staticGeometry || {}
281
+ return (
282
+ AltiumScene3dShapeStackOwnerAdapter.#isShapeBasedBody(
283
+ componentBody
284
+ ) &&
285
+ !componentBody?.embedded &&
286
+ String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
287
+ String(geometry?.status || '').toLowerCase() === 'complete' &&
288
+ Number(geometry?.heightMil ?? componentBody?.overallHeightMil) > 0
289
+ )
290
+ }
291
+
292
+ /**
293
+ * Finds a carrier assignment for one positive-height sub-body.
294
+ * @param {object | null} componentBody Source component-body row.
295
+ * @param {object[]} assignments Carrier assignments.
296
+ * @returns {object | null}
297
+ */
298
+ static #assignmentForBody(componentBody, assignments) {
299
+ if (
300
+ !componentBody ||
301
+ !AltiumScene3dShapeStackOwnerAdapter.#isShapeBasedBody(
302
+ componentBody
303
+ ) ||
304
+ Number(componentBody?.standoffHeightMil || 0) <= 0
305
+ ) {
306
+ return null
307
+ }
308
+
309
+ const standoff = Number(componentBody.standoffHeightMil)
310
+ const bodyPosition = AltiumScene3dShapeStackOwnerAdapter.#point(
311
+ componentBody.positionMil
312
+ )
313
+
314
+ return (
315
+ assignments
316
+ .map((assignment) => ({
317
+ assignment,
318
+ heightError: Math.abs(
319
+ Number(assignment.heightMil || 0) - standoff
320
+ ),
321
+ distance: AltiumScene3dShapeStackOwnerAdapter.#distance(
322
+ bodyPosition,
323
+ assignment.owner
324
+ )
325
+ }))
326
+ .filter(
327
+ (candidate) =>
328
+ candidate.heightError <=
329
+ AltiumScene3dShapeStackOwnerAdapter
330
+ .#HEIGHT_TOLERANCE_MIL &&
331
+ candidate.distance <=
332
+ AltiumScene3dShapeStackOwnerAdapter
333
+ .#STACK_BODY_RADIUS_MIL
334
+ )
335
+ .sort(
336
+ (left, right) =>
337
+ left.heightError - right.heightError ||
338
+ left.distance - right.distance
339
+ )[0]?.assignment || null
340
+ )
341
+ }
342
+
343
+ /**
344
+ * Applies one owner component to an external placement.
345
+ * @param {object} placement External placement.
346
+ * @param {object} componentBody Source component-body row.
347
+ * @param {{ owner: object, groupKey: string }} assignment Stack assignment.
348
+ * @param {object | undefined} board Scene board metadata.
349
+ * @returns {object}
350
+ */
351
+ static #withOwner(placement, componentBody, assignment, board) {
352
+ const owner = assignment.owner
353
+ const mountSide =
354
+ AltiumScene3dShapeStackOwnerAdapter.#mountSide(owner) ||
355
+ placement.mountSide
356
+ const rotationDeg = AltiumScene3dShapeStackOwnerAdapter.#normalizeAngle(
357
+ owner.rotation
358
+ )
359
+ const ownerOffset = {
360
+ x:
361
+ Number(componentBody?.positionMil?.x || 0) -
362
+ Number(owner?.x || 0),
363
+ y:
364
+ Number(componentBody?.positionMil?.y || 0) -
365
+ Number(owner?.y || 0)
366
+ }
367
+ const standoff = Number(componentBody?.standoffHeightMil || 0)
368
+ const renderableOffset =
369
+ AltiumScene3dShapeStackOwnerAdapter.#renderableOffset(
370
+ { mountSide, rotationDeg },
371
+ ownerOffset,
372
+ standoff
373
+ )
374
+
375
+ return {
376
+ ...placement,
377
+ designator: String(owner?.designator || placement.designator),
378
+ mountSide,
379
+ rotationDeg,
380
+ positionMil: {
381
+ ...(placement.positionMil || {}),
382
+ x: Number(owner?.x || 0) - Number(board?.centerX || 0),
383
+ y: Number(owner?.y || 0) - Number(board?.centerY || 0),
384
+ z: AltiumScene3dShapeStackOwnerAdapter.#faceZ(mountSide, board)
385
+ },
386
+ projection: {
387
+ ...(placement.projection || {}),
388
+ source: 'authored-shape-stack',
389
+ reason: 'Altium shape-based sub-body is seated on an authored carrier stack.'
390
+ },
391
+ coLocatedVariantGroupKey: assignment.groupKey,
392
+ modelTransform: {
393
+ ...(placement.modelTransform || {}),
394
+ dzMil: standoff,
395
+ offsetMil: renderableOffset,
396
+ ownerAnchorOffsetMil: ownerOffset
397
+ }
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Converts a board-space owner offset into render local coordinates.
403
+ * @param {{ mountSide?: string, rotationDeg?: number }} placement Placement.
404
+ * @param {{ x: number, y: number }} offset Board-space offset.
405
+ * @param {number} standoff Vertical stack offset.
406
+ * @returns {{ x: number, y: number, z: number }}
407
+ */
408
+ static #renderableOffset(placement, offset, standoff) {
409
+ const rotationRad =
410
+ (-AltiumScene3dShapeStackOwnerAdapter.#normalizeAngle(
411
+ placement.rotationDeg
412
+ ) *
413
+ Math.PI) /
414
+ 180
415
+ const cos = Math.cos(rotationRad)
416
+ const sin = Math.sin(rotationRad)
417
+ const sourceY =
418
+ String(placement?.mountSide || '').toLowerCase() === 'bottom'
419
+ ? Number(offset.y || 0)
420
+ : -Number(offset.y || 0)
421
+ const x = Number(offset.x || 0) * cos - sourceY * sin
422
+ const y = Number(offset.x || 0) * sin + sourceY * cos
423
+
424
+ return {
425
+ x: AltiumScene3dShapeStackOwnerAdapter.#round(x),
426
+ y:
427
+ String(placement?.mountSide || '').toLowerCase() === 'bottom'
428
+ ? AltiumScene3dShapeStackOwnerAdapter.#round(-y)
429
+ : AltiumScene3dShapeStackOwnerAdapter.#round(y),
430
+ z: AltiumScene3dShapeStackOwnerAdapter.#round(standoff)
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Checks whether one static placement came from one carrier base row.
436
+ * @param {object} placement Static placement.
437
+ * @param {object} base Source base row.
438
+ * @returns {boolean}
439
+ */
440
+ static #isSameStaticBody(placement, base) {
441
+ return (
442
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
443
+ placement?.bodyPositionMil,
444
+ base?.positionMil
445
+ ) <= 0.01 &&
446
+ Math.abs(
447
+ Number(placement?.geometry?.heightMil || 0) -
448
+ Number(
449
+ base?.staticGeometry?.heightMil ??
450
+ base?.overallHeightMil ??
451
+ 0
452
+ )
453
+ ) <= AltiumScene3dShapeStackOwnerAdapter.#HEIGHT_TOLERANCE_MIL
454
+ )
455
+ }
456
+
457
+ /**
458
+ * Resolves the source component body row for one external placement.
459
+ * @param {object} placement External placement.
460
+ * @param {object[]} componentBodies Source body rows.
461
+ * @param {Set<object>} usedComponentBodies Already consumed body rows.
462
+ * @returns {object | null}
463
+ */
464
+ static #resolveComponentBody(
465
+ placement,
466
+ componentBodies,
467
+ usedComponentBodies
468
+ ) {
469
+ return (
470
+ componentBodies
471
+ .filter(
472
+ (componentBody) => !usedComponentBodies.has(componentBody)
473
+ )
474
+ .map((componentBody) => ({
475
+ componentBody,
476
+ distance: AltiumScene3dShapeStackOwnerAdapter.#distance(
477
+ placement?.bodyPositionMil,
478
+ componentBody?.positionMil
479
+ ),
480
+ standoffError:
481
+ AltiumScene3dShapeStackOwnerAdapter.#standoffError(
482
+ placement,
483
+ componentBody
484
+ ),
485
+ score: AltiumScene3dShapeStackOwnerAdapter.#identityScore(
486
+ placement,
487
+ componentBody
488
+ )
489
+ }))
490
+ .filter((candidate) => candidate.distance <= 0.01)
491
+ .sort(
492
+ (left, right) =>
493
+ right.score - left.score ||
494
+ left.standoffError - right.standoffError ||
495
+ left.distance - right.distance
496
+ )[0]?.componentBody || null
497
+ )
498
+ }
499
+
500
+ /**
501
+ * Scores whether a source body row matches an already-known stack height.
502
+ * @param {object} placement External placement.
503
+ * @param {object} componentBody Source body row.
504
+ * @returns {number}
505
+ */
506
+ static #standoffError(placement, componentBody) {
507
+ const placementHeight = Number(
508
+ placement?.modelTransform?.offsetMil?.z ??
509
+ placement?.modelTransform?.dzMil
510
+ )
511
+ const bodyStandoff = Number(componentBody?.standoffHeightMil)
512
+ if (
513
+ !Number.isFinite(placementHeight) ||
514
+ !Number.isFinite(bodyStandoff) ||
515
+ placementHeight <= 0
516
+ ) {
517
+ return 0
518
+ }
519
+
520
+ return Math.abs(placementHeight - bodyStandoff)
521
+ }
522
+
523
+ /**
524
+ * Scores whether a source body row belongs to one placement.
525
+ * @param {object} placement External placement.
526
+ * @param {object} componentBody Source body row.
527
+ * @returns {number}
528
+ */
529
+ static #identityScore(placement, componentBody) {
530
+ const placementText = AltiumScene3dShapeStackOwnerAdapter.#identityText(
531
+ [placement?.externalModel?.name, placement?.designator]
532
+ )
533
+ const bodyText = AltiumScene3dShapeStackOwnerAdapter.#identityText([
534
+ componentBody?.name,
535
+ componentBody?.identifier
536
+ ])
537
+
538
+ return placementText && bodyText && placementText.includes(bodyText)
539
+ ? bodyText.length
540
+ : 0
541
+ }
542
+
543
+ /**
544
+ * Checks whether a component is a timing-package owner.
545
+ * @param {object} component Source component.
546
+ * @returns {boolean}
547
+ */
548
+ static #isTimingComponent(component) {
549
+ const designator = String(component?.designator || '').trim()
550
+
551
+ return (
552
+ TIMING_DESIGNATOR_PATTERN.test(designator) ||
553
+ TIMING_PACKAGE_PATTERN.test(
554
+ [
555
+ component?.pattern,
556
+ component?.source,
557
+ component?.description,
558
+ component?.provenance?.footprintDescription,
559
+ ...Object.values(component?.parameters || {})
560
+ ]
561
+ .map((value) => String(value || ''))
562
+ .join(' ')
563
+ )
564
+ )
565
+ }
566
+
567
+ /**
568
+ * Scores how strongly a component looks like the fitted stack owner.
569
+ * @param {object} component Source component.
570
+ * @returns {number}
571
+ */
572
+ static #ownerRank(component) {
573
+ const parameters = Object.values(component?.parameters || {}).filter(
574
+ (value) => String(value || '').trim()
575
+ )
576
+ return (
577
+ parameters.length * 10 +
578
+ (TIMING_DESIGNATOR_PATTERN.test(component?.designator) ? 5 : 0) +
579
+ (TIMING_PACKAGE_PATTERN.test(
580
+ [
581
+ component?.pattern,
582
+ component?.source,
583
+ component?.description,
584
+ component?.provenance?.footprintDescription
585
+ ]
586
+ .map((value) => String(value || ''))
587
+ .join(' ')
588
+ )
589
+ ? 3
590
+ : 0)
591
+ )
592
+ }
593
+
594
+ /**
595
+ * Checks whether one body row came from shape-based component metadata.
596
+ * @param {object | null | undefined} componentBody Source body row.
597
+ * @returns {boolean}
598
+ */
599
+ static #isShapeBasedBody(componentBody) {
600
+ return String(componentBody?.sourceStream || '').includes(
601
+ 'ShapeBasedComponentBodies'
602
+ )
603
+ }
604
+
605
+ /**
606
+ * Resolves one component's mount side.
607
+ * @param {object} component Source component.
608
+ * @returns {'top' | 'bottom'}
609
+ */
610
+ static #mountSide(component) {
611
+ const layer = String(component?.layer || '').toUpperCase()
612
+ return layer.includes('BOTTOM') || layer === 'BOT' ? 'bottom' : 'top'
613
+ }
614
+
615
+ /**
616
+ * Resolves one board face Z coordinate.
617
+ * @param {string} mountSide Mount side.
618
+ * @param {object | undefined} board Scene board metadata.
619
+ * @returns {number}
620
+ */
621
+ static #faceZ(mountSide, board) {
622
+ const thickness = Number(board?.thicknessMil) || 63
623
+ return String(mountSide || '').toLowerCase() === 'bottom'
624
+ ? -thickness / 2
625
+ : thickness / 2
626
+ }
627
+
628
+ /**
629
+ * Normalizes an angle into [0, 360).
630
+ * @param {number} angle Candidate angle.
631
+ * @returns {number}
632
+ */
633
+ static #normalizeAngle(angle) {
634
+ const normalized = Number(angle || 0) % 360
635
+ return normalized < 0 ? normalized + 360 : normalized
636
+ }
637
+
638
+ /**
639
+ * Measures the XY distance between two board points.
640
+ * @param {object | undefined} first First point.
641
+ * @param {object | undefined} second Second point.
642
+ * @returns {number}
643
+ */
644
+ static #distance(first, second) {
645
+ return Math.hypot(
646
+ Number(first?.x || 0) - Number(second?.x || 0),
647
+ Number(first?.y || 0) - Number(second?.y || 0)
648
+ )
649
+ }
650
+
651
+ /**
652
+ * Normalizes one point.
653
+ * @param {object | undefined} point Source point.
654
+ * @returns {{ x: number, y: number }}
655
+ */
656
+ static #point(point) {
657
+ return {
658
+ x: Number(point?.x || 0),
659
+ y: Number(point?.y || 0)
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Builds a stable key for co-located authored stack variants.
665
+ * @param {{ x?: number, y?: number }} point Stack anchor.
666
+ * @returns {string}
667
+ */
668
+ static #groupKey(point) {
669
+ return [
670
+ 'altium-shape-stack',
671
+ AltiumScene3dShapeStackOwnerAdapter.#round(point?.x || 0),
672
+ AltiumScene3dShapeStackOwnerAdapter.#round(point?.y || 0)
673
+ ].join(':')
674
+ }
675
+
676
+ /**
677
+ * Builds normalized identity text.
678
+ * @param {unknown[]} values Identity values.
679
+ * @returns {string}
680
+ */
681
+ static #identityText(values) {
682
+ return values
683
+ .map((value) =>
684
+ String(value || '')
685
+ .replace(/\.[^.]+$/, '')
686
+ .trim()
687
+ .toLowerCase()
688
+ .replace(/[^a-z0-9]+/gu, '')
689
+ )
690
+ .filter(Boolean)
691
+ .join(' ')
692
+ }
693
+
694
+ /**
695
+ * Rounds one mil value for stable scene output.
696
+ * @param {number} value Candidate value.
697
+ * @returns {number}
698
+ */
699
+ static #round(value) {
700
+ const rounded = Math.round(Number(value) * 10000) / 10000
701
+ return Object.is(rounded, -0) ? 0 : rounded
702
+ }
703
+ }