altium-toolkit 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/scene3d.mjs
CHANGED
|
@@ -7,3 +7,4 @@ export { PcbScene3dModelRegistry } from './ui/PcbScene3dModelRegistry.mjs'
|
|
|
7
7
|
export { PcbScene3dPackages } from './ui/PcbScene3dPackages.mjs'
|
|
8
8
|
export { PcbScene3dScenePreparator } from './ui/PcbScene3dScenePreparator.mjs'
|
|
9
9
|
export { PcbScene3dSummaryRenderer } from './ui/PcbScene3dSummaryRenderer.mjs'
|
|
10
|
+
export { PcbScene3dTextBoxLayoutResolver } from './ui/PcbScene3dTextBoxLayoutResolver.mjs'
|
|
@@ -6,6 +6,8 @@ import { PcbEdgeFacingGlyphNormalizer } from './PcbEdgeFacingGlyphNormalizer.mjs
|
|
|
6
6
|
import { PcbScene3dDrillCutoutBuilder } from './PcbScene3dDrillCutoutBuilder.mjs'
|
|
7
7
|
import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
|
|
8
8
|
import { PcbScene3dPackages } from './PcbScene3dPackages.mjs'
|
|
9
|
+
import { PcbScene3dPlacementSideResolver } from './PcbScene3dPlacementSideResolver.mjs'
|
|
10
|
+
import { PcbScene3dTextBoxLayoutResolver } from './PcbScene3dTextBoxLayoutResolver.mjs'
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Builds deterministic 3D scene data from the normalized PCB model.
|
|
@@ -114,6 +116,7 @@ export class PcbScene3dBuilder {
|
|
|
114
116
|
PcbScene3dBuilder.#buildExternalPlacement(
|
|
115
117
|
componentBody,
|
|
116
118
|
bodyMatches[index],
|
|
119
|
+
components,
|
|
117
120
|
board,
|
|
118
121
|
thicknessMil,
|
|
119
122
|
modelRegistry
|
|
@@ -195,6 +198,7 @@ export class PcbScene3dBuilder {
|
|
|
195
198
|
* body metadata.
|
|
196
199
|
* @param {{ modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number }} componentBody
|
|
197
200
|
* @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent
|
|
201
|
+
* @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components
|
|
198
202
|
* @param {{ centerX: number, centerY: number }} board
|
|
199
203
|
* @param {number} thicknessMil
|
|
200
204
|
* @param {{ resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null} modelRegistry
|
|
@@ -203,6 +207,7 @@ export class PcbScene3dBuilder {
|
|
|
203
207
|
static #buildExternalPlacement(
|
|
204
208
|
componentBody,
|
|
205
209
|
matchedComponent,
|
|
210
|
+
components,
|
|
206
211
|
board,
|
|
207
212
|
thicknessMil,
|
|
208
213
|
modelRegistry
|
|
@@ -220,9 +225,10 @@ export class PcbScene3dBuilder {
|
|
|
220
225
|
return null
|
|
221
226
|
}
|
|
222
227
|
|
|
223
|
-
const mountSide =
|
|
228
|
+
const mountSide = PcbScene3dPlacementSideResolver.resolvePlacementSide(
|
|
224
229
|
componentBody,
|
|
225
|
-
matchedComponent
|
|
230
|
+
matchedComponent,
|
|
231
|
+
components
|
|
226
232
|
)
|
|
227
233
|
const halfBoardThickness = thicknessMil / 2
|
|
228
234
|
const sourcePosition =
|
|
@@ -327,7 +333,9 @@ export class PcbScene3dBuilder {
|
|
|
327
333
|
const groupedBodyIndexes = new Map()
|
|
328
334
|
componentBodies.forEach((componentBody, bodyIndex) => {
|
|
329
335
|
const groupKey =
|
|
330
|
-
|
|
336
|
+
PcbScene3dPlacementSideResolver.resolveBodyGroupKey(
|
|
337
|
+
componentBody
|
|
338
|
+
)
|
|
331
339
|
if (!groupedBodyIndexes.has(groupKey)) {
|
|
332
340
|
groupedBodyIndexes.set(groupKey, [])
|
|
333
341
|
}
|
|
@@ -355,7 +363,7 @@ export class PcbScene3dBuilder {
|
|
|
355
363
|
bodyIndexes.includes(
|
|
356
364
|
matches.indexOf(components[componentIndex])
|
|
357
365
|
)) &&
|
|
358
|
-
|
|
366
|
+
PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
|
|
359
367
|
referenceBody,
|
|
360
368
|
component
|
|
361
369
|
) > 0
|
|
@@ -401,7 +409,9 @@ export class PcbScene3dBuilder {
|
|
|
401
409
|
|
|
402
410
|
for (const componentBody of componentBodies) {
|
|
403
411
|
const groupKey =
|
|
404
|
-
|
|
412
|
+
PcbScene3dPlacementSideResolver.resolveBodyGroupKey(
|
|
413
|
+
componentBody
|
|
414
|
+
)
|
|
405
415
|
bodyGroupCounts.set(
|
|
406
416
|
groupKey,
|
|
407
417
|
(bodyGroupCounts.get(groupKey) || 0) + 1
|
|
@@ -416,7 +426,7 @@ export class PcbScene3dBuilder {
|
|
|
416
426
|
groupKey,
|
|
417
427
|
components.filter(
|
|
418
428
|
(component) =>
|
|
419
|
-
|
|
429
|
+
PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
|
|
420
430
|
componentBody,
|
|
421
431
|
component
|
|
422
432
|
) > 0
|
|
@@ -450,7 +460,7 @@ export class PcbScene3dBuilder {
|
|
|
450
460
|
}
|
|
451
461
|
|
|
452
462
|
if (
|
|
453
|
-
|
|
463
|
+
PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
|
|
454
464
|
componentBody,
|
|
455
465
|
component
|
|
456
466
|
) <= 0
|
|
@@ -458,7 +468,8 @@ export class PcbScene3dBuilder {
|
|
|
458
468
|
return false
|
|
459
469
|
}
|
|
460
470
|
|
|
461
|
-
const groupKey =
|
|
471
|
+
const groupKey =
|
|
472
|
+
PcbScene3dPlacementSideResolver.resolveBodyGroupKey(componentBody)
|
|
462
473
|
const bodyCount = matchContext.bodyGroupCounts.get(groupKey) || 0
|
|
463
474
|
const candidateCount =
|
|
464
475
|
matchContext.candidateComponentCounts.get(groupKey) || 0
|
|
@@ -635,40 +646,6 @@ export class PcbScene3dBuilder {
|
|
|
635
646
|
}
|
|
636
647
|
}
|
|
637
648
|
|
|
638
|
-
/**
|
|
639
|
-
* Resolves which board side one explicit model should mount on.
|
|
640
|
-
* @param {{ layer?: string }} componentBody
|
|
641
|
-
* @param {{ layer?: string } | null} matchedComponent
|
|
642
|
-
* @returns {'top' | 'bottom'}
|
|
643
|
-
*/
|
|
644
|
-
static #resolveExternalPlacementMountSide(componentBody, matchedComponent) {
|
|
645
|
-
const bodySide = PcbScene3dBuilder.#resolveMechanicalLayerSide(
|
|
646
|
-
componentBody?.layer
|
|
647
|
-
)
|
|
648
|
-
if (bodySide) {
|
|
649
|
-
return bodySide
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
return String(matchedComponent?.layer || 'TOP').toUpperCase() ===
|
|
653
|
-
'BOTTOM'
|
|
654
|
-
? 'bottom'
|
|
655
|
-
: 'top'
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/**
|
|
659
|
-
* Resolves a common Altium top/bottom mechanical layer pair.
|
|
660
|
-
* @param {string | undefined} layer
|
|
661
|
-
* @returns {'top' | 'bottom' | null}
|
|
662
|
-
*/
|
|
663
|
-
static #resolveMechanicalLayerSide(layer) {
|
|
664
|
-
const match = String(layer || '').match(/^MECHANICAL\s*(\d+)$/i)
|
|
665
|
-
if (!match) {
|
|
666
|
-
return null
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
return Number(match[1]) % 2 === 0 ? 'bottom' : 'top'
|
|
670
|
-
}
|
|
671
|
-
|
|
672
649
|
/**
|
|
673
650
|
* Resolves the authored placement rotation for one explicit external model.
|
|
674
651
|
* Altium stores the 3D model's board-facing yaw in MODEL.3D.ROTZ.
|
|
@@ -879,9 +856,11 @@ export class PcbScene3dBuilder {
|
|
|
879
856
|
*/
|
|
880
857
|
static #normalizeSilkscreenText(text, side) {
|
|
881
858
|
const height = Math.max(Number(text?.height || 0), 1)
|
|
859
|
+
const textBox = PcbScene3dTextBoxLayoutResolver.resolve(text)
|
|
882
860
|
|
|
883
861
|
return {
|
|
884
862
|
...text,
|
|
863
|
+
...(textBox ? { textBox } : {}),
|
|
885
864
|
text: String(text?.text ?? text?.value ?? ''),
|
|
886
865
|
value: String(text?.text ?? text?.value ?? ''),
|
|
887
866
|
sizeX: height,
|
|
@@ -1159,85 +1138,6 @@ export class PcbScene3dBuilder {
|
|
|
1159
1138
|
)
|
|
1160
1139
|
}
|
|
1161
1140
|
|
|
1162
|
-
/**
|
|
1163
|
-
* Resolves the grouping key for repeated component-body matching.
|
|
1164
|
-
* @param {{ modelId?: string, name?: string, identifier?: string }} componentBody
|
|
1165
|
-
* @returns {string}
|
|
1166
|
-
*/
|
|
1167
|
-
static #resolveBodyGroupKey(componentBody) {
|
|
1168
|
-
return PcbScene3dBuilder.#normalizeLookupToken(
|
|
1169
|
-
componentBody?.modelId ||
|
|
1170
|
-
componentBody?.name ||
|
|
1171
|
-
componentBody?.identifier
|
|
1172
|
-
)
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
/**
|
|
1176
|
-
* Scores how strongly one component record appears to belong to one body
|
|
1177
|
-
* record based on shared model/footprint tokens.
|
|
1178
|
-
* @param {{ name?: string, identifier?: string }} componentBody
|
|
1179
|
-
* @param {{ pattern?: string, source?: string, modelPath?: string }} component
|
|
1180
|
-
* @returns {number}
|
|
1181
|
-
*/
|
|
1182
|
-
static #scoreBodyComponentAffinity(componentBody, component) {
|
|
1183
|
-
const bodyTokens = PcbScene3dBuilder.#collectMeaningfulTokens([
|
|
1184
|
-
componentBody?.identifier,
|
|
1185
|
-
String(componentBody?.name || '').replace(/\.[^.]+$/, '')
|
|
1186
|
-
])
|
|
1187
|
-
const componentTokens = PcbScene3dBuilder.#collectMeaningfulTokens([
|
|
1188
|
-
component?.pattern,
|
|
1189
|
-
component?.source,
|
|
1190
|
-
component?.modelPath
|
|
1191
|
-
])
|
|
1192
|
-
let score = 0
|
|
1193
|
-
|
|
1194
|
-
bodyTokens.forEach((token) => {
|
|
1195
|
-
if (componentTokens.has(token)) {
|
|
1196
|
-
score += token.length
|
|
1197
|
-
}
|
|
1198
|
-
})
|
|
1199
|
-
|
|
1200
|
-
return score
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
/**
|
|
1204
|
-
* Collects normalized model tokens from free-form strings.
|
|
1205
|
-
* @param {(string | undefined)[]} values
|
|
1206
|
-
* @returns {Set<string>}
|
|
1207
|
-
*/
|
|
1208
|
-
static #collectMeaningfulTokens(values) {
|
|
1209
|
-
const tokens = new Set()
|
|
1210
|
-
|
|
1211
|
-
;(Array.isArray(values) ? values : []).forEach((value) => {
|
|
1212
|
-
String(value || '')
|
|
1213
|
-
.toLowerCase()
|
|
1214
|
-
.split(/[^a-z0-9]+/g)
|
|
1215
|
-
.forEach((fragment) => {
|
|
1216
|
-
;(fragment.match(/[a-z]+|\d+/g) || []).forEach((token) => {
|
|
1217
|
-
if (PcbScene3dBuilder.#isMeaningfulToken(token)) {
|
|
1218
|
-
tokens.add(token)
|
|
1219
|
-
}
|
|
1220
|
-
})
|
|
1221
|
-
})
|
|
1222
|
-
})
|
|
1223
|
-
|
|
1224
|
-
return tokens
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
/**
|
|
1228
|
-
* Returns true when one normalized token carries useful model identity.
|
|
1229
|
-
* @param {string} token
|
|
1230
|
-
* @returns {boolean}
|
|
1231
|
-
*/
|
|
1232
|
-
static #isMeaningfulToken(token) {
|
|
1233
|
-
return (
|
|
1234
|
-
String(token || '').length >= 2 &&
|
|
1235
|
-
!new Set(['con', 'step', 'stp', 'model', 'default', 'black']).has(
|
|
1236
|
-
String(token || '')
|
|
1237
|
-
)
|
|
1238
|
-
)
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
1141
|
/**
|
|
1242
1142
|
* Returns true when one unresolved body anchor still lies close enough to
|
|
1243
1143
|
* the board envelope to be renderable without a component match.
|
|
@@ -1258,17 +1158,6 @@ export class PcbScene3dBuilder {
|
|
|
1258
1158
|
return bodyX >= minX && bodyX <= maxX && bodyY >= minY && bodyY <= maxY
|
|
1259
1159
|
}
|
|
1260
1160
|
|
|
1261
|
-
/**
|
|
1262
|
-
* Normalizes one lookup token for repeated-model grouping.
|
|
1263
|
-
* @param {string | undefined} value
|
|
1264
|
-
* @returns {string}
|
|
1265
|
-
*/
|
|
1266
|
-
static #normalizeLookupToken(value) {
|
|
1267
|
-
return String(value || '')
|
|
1268
|
-
.toLowerCase()
|
|
1269
|
-
.replace(/[^a-z0-9]+/g, '')
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
1161
|
/**
|
|
1273
1162
|
* Normalizes one angle into the range [0, 360).
|
|
1274
1163
|
* @param {number} angle
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves 3D component-body identity and mount-side hints.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbScene3dPlacementSideResolver {
|
|
9
|
+
static #NEARBY_SIDE_HINT_MAX_DISTANCE_MIL = 600
|
|
10
|
+
static #NEGATIVE_STANDOFF_SIDE_RATIO = 0.3
|
|
11
|
+
static #MIN_NEGATIVE_STANDOFF_SIDE_MIL = 20
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves which board side one explicit model should mount on.
|
|
15
|
+
* @param {{ layer?: string, standoffHeightMil?: number | null, overallHeightMil?: number | null }} componentBody
|
|
16
|
+
* @param {{ layer?: string } | null} matchedComponent
|
|
17
|
+
* @param {{ layer?: string, pattern?: string, source?: string, modelPath?: string, x?: number, y?: number }[]} components
|
|
18
|
+
* @returns {'top' | 'bottom'}
|
|
19
|
+
*/
|
|
20
|
+
static resolvePlacementSide(
|
|
21
|
+
componentBody,
|
|
22
|
+
matchedComponent,
|
|
23
|
+
components = []
|
|
24
|
+
) {
|
|
25
|
+
const matchedSide =
|
|
26
|
+
PcbScene3dPlacementSideResolver.#resolveComponentLayerSide(
|
|
27
|
+
matchedComponent?.layer
|
|
28
|
+
)
|
|
29
|
+
if (matchedSide) {
|
|
30
|
+
return matchedSide
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const standoffSide =
|
|
34
|
+
PcbScene3dPlacementSideResolver.#resolveStandoffSide(componentBody)
|
|
35
|
+
if (standoffSide) {
|
|
36
|
+
return standoffSide
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const nearbySide =
|
|
40
|
+
PcbScene3dPlacementSideResolver.#resolveNearbyComponentSide(
|
|
41
|
+
componentBody,
|
|
42
|
+
components
|
|
43
|
+
)
|
|
44
|
+
if (nearbySide) {
|
|
45
|
+
return nearbySide
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
PcbScene3dPlacementSideResolver.#resolveMechanicalLayerSide(
|
|
50
|
+
componentBody?.layer
|
|
51
|
+
) || 'top'
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves the grouping key for repeated component-body matching.
|
|
57
|
+
* @param {{ modelId?: string, name?: string, identifier?: string }} componentBody
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
static resolveBodyGroupKey(componentBody) {
|
|
61
|
+
return PcbScene3dPlacementSideResolver.#normalizeLookupToken(
|
|
62
|
+
componentBody?.modelId ||
|
|
63
|
+
componentBody?.name ||
|
|
64
|
+
componentBody?.identifier
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Scores how strongly one component record appears to belong to one body
|
|
70
|
+
* record based on shared model/footprint tokens.
|
|
71
|
+
* @param {{ name?: string, identifier?: string }} componentBody
|
|
72
|
+
* @param {{ pattern?: string, source?: string, modelPath?: string }} component
|
|
73
|
+
* @returns {number}
|
|
74
|
+
*/
|
|
75
|
+
static scoreBodyComponentAffinity(componentBody, component) {
|
|
76
|
+
const bodyTokens =
|
|
77
|
+
PcbScene3dPlacementSideResolver.#collectMeaningfulTokens([
|
|
78
|
+
componentBody?.identifier,
|
|
79
|
+
String(componentBody?.name || '').replace(/\.[^.]+$/, '')
|
|
80
|
+
])
|
|
81
|
+
const componentTokens =
|
|
82
|
+
PcbScene3dPlacementSideResolver.#collectMeaningfulTokens([
|
|
83
|
+
component?.pattern,
|
|
84
|
+
component?.source,
|
|
85
|
+
component?.modelPath
|
|
86
|
+
])
|
|
87
|
+
let score = 0
|
|
88
|
+
|
|
89
|
+
bodyTokens.forEach((token) => {
|
|
90
|
+
if (componentTokens.has(token)) {
|
|
91
|
+
score += token.length
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return score
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolves side from the normalized component layer.
|
|
100
|
+
* @param {string | undefined} layer
|
|
101
|
+
* @returns {'top' | 'bottom' | null}
|
|
102
|
+
*/
|
|
103
|
+
static #resolveComponentLayerSide(layer) {
|
|
104
|
+
const normalized = String(layer || '')
|
|
105
|
+
.trim()
|
|
106
|
+
.toUpperCase()
|
|
107
|
+
|
|
108
|
+
if (!normalized) {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (normalized.includes('BOTTOM') || normalized === 'BOT') {
|
|
113
|
+
return 'bottom'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (normalized.includes('TOP')) {
|
|
117
|
+
return 'top'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolves underside bodies from significant negative standoff metadata.
|
|
125
|
+
* @param {{ standoffHeightMil?: number | null, overallHeightMil?: number | null } | null} componentBody
|
|
126
|
+
* @returns {'bottom' | null}
|
|
127
|
+
*/
|
|
128
|
+
static #resolveStandoffSide(componentBody) {
|
|
129
|
+
const standoff = Number(componentBody?.standoffHeightMil)
|
|
130
|
+
const overallHeight = Number(componentBody?.overallHeightMil)
|
|
131
|
+
|
|
132
|
+
if (!Number.isFinite(standoff) || standoff >= 0) {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const threshold =
|
|
137
|
+
Number.isFinite(overallHeight) && overallHeight > 0
|
|
138
|
+
? Math.max(
|
|
139
|
+
overallHeight *
|
|
140
|
+
PcbScene3dPlacementSideResolver
|
|
141
|
+
.#NEGATIVE_STANDOFF_SIDE_RATIO,
|
|
142
|
+
PcbScene3dPlacementSideResolver
|
|
143
|
+
.#MIN_NEGATIVE_STANDOFF_SIDE_MIL
|
|
144
|
+
)
|
|
145
|
+
: PcbScene3dPlacementSideResolver
|
|
146
|
+
.#MIN_NEGATIVE_STANDOFF_SIDE_MIL
|
|
147
|
+
|
|
148
|
+
return Math.abs(standoff) >= threshold ? 'bottom' : null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolves side from the nearest footprint-compatible component.
|
|
153
|
+
* @param {{ positionMil?: { x?: number, y?: number } } & { name?: string, identifier?: string }} componentBody
|
|
154
|
+
* @param {{ layer?: string, pattern?: string, source?: string, modelPath?: string, x?: number, y?: number }[]} components
|
|
155
|
+
* @returns {'top' | 'bottom' | null}
|
|
156
|
+
*/
|
|
157
|
+
static #resolveNearbyComponentSide(componentBody, components) {
|
|
158
|
+
const candidates = (Array.isArray(components) ? components : [])
|
|
159
|
+
.map((component) => ({
|
|
160
|
+
component,
|
|
161
|
+
side: PcbScene3dPlacementSideResolver.#resolveComponentLayerSide(
|
|
162
|
+
component?.layer
|
|
163
|
+
),
|
|
164
|
+
score: PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
|
|
165
|
+
componentBody,
|
|
166
|
+
component
|
|
167
|
+
),
|
|
168
|
+
distance:
|
|
169
|
+
PcbScene3dPlacementSideResolver.#distanceBetweenBodyAndComponent(
|
|
170
|
+
componentBody,
|
|
171
|
+
component
|
|
172
|
+
)
|
|
173
|
+
}))
|
|
174
|
+
.filter(
|
|
175
|
+
(candidate) =>
|
|
176
|
+
candidate.side &&
|
|
177
|
+
candidate.score > 0 &&
|
|
178
|
+
candidate.distance <=
|
|
179
|
+
PcbScene3dPlacementSideResolver
|
|
180
|
+
.#NEARBY_SIDE_HINT_MAX_DISTANCE_MIL
|
|
181
|
+
)
|
|
182
|
+
.sort(
|
|
183
|
+
(left, right) =>
|
|
184
|
+
right.score - left.score || left.distance - right.distance
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return candidates[0]?.side || null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolves a common Altium top/bottom mechanical layer pair.
|
|
192
|
+
* @param {string | undefined} layer
|
|
193
|
+
* @returns {'top' | 'bottom' | null}
|
|
194
|
+
*/
|
|
195
|
+
static #resolveMechanicalLayerSide(layer) {
|
|
196
|
+
const match = String(layer || '').match(/^MECHANICAL\s*(\d+)$/i)
|
|
197
|
+
if (!match) {
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return Number(match[1]) % 2 === 0 ? 'bottom' : 'top'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Returns the euclidean distance between one body anchor and one component
|
|
206
|
+
* anchor.
|
|
207
|
+
* @param {{ positionMil?: { x?: number, y?: number } }} componentBody
|
|
208
|
+
* @param {{ x?: number, y?: number }} component
|
|
209
|
+
* @returns {number}
|
|
210
|
+
*/
|
|
211
|
+
static #distanceBetweenBodyAndComponent(componentBody, component) {
|
|
212
|
+
return Math.hypot(
|
|
213
|
+
Number(component?.x || 0) -
|
|
214
|
+
Number(componentBody?.positionMil?.x || 0),
|
|
215
|
+
Number(component?.y || 0) -
|
|
216
|
+
Number(componentBody?.positionMil?.y || 0)
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Collects normalized model tokens from free-form strings.
|
|
222
|
+
* @param {(string | undefined)[]} values
|
|
223
|
+
* @returns {Set<string>}
|
|
224
|
+
*/
|
|
225
|
+
static #collectMeaningfulTokens(values) {
|
|
226
|
+
const tokens = new Set()
|
|
227
|
+
|
|
228
|
+
;(Array.isArray(values) ? values : []).forEach((value) => {
|
|
229
|
+
String(value || '')
|
|
230
|
+
.toLowerCase()
|
|
231
|
+
.split(/[^a-z0-9]+/g)
|
|
232
|
+
.forEach((fragment) => {
|
|
233
|
+
;(fragment.match(/[a-z]+|\d+/g) || []).forEach((token) => {
|
|
234
|
+
if (
|
|
235
|
+
PcbScene3dPlacementSideResolver.#isMeaningfulToken(
|
|
236
|
+
token
|
|
237
|
+
)
|
|
238
|
+
) {
|
|
239
|
+
tokens.add(token)
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
return tokens
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Returns true when one normalized token carries useful model identity.
|
|
250
|
+
* @param {string} token
|
|
251
|
+
* @returns {boolean}
|
|
252
|
+
*/
|
|
253
|
+
static #isMeaningfulToken(token) {
|
|
254
|
+
return (
|
|
255
|
+
String(token || '').length >= 2 &&
|
|
256
|
+
!new Set(['con', 'step', 'stp', 'model', 'default', 'black']).has(
|
|
257
|
+
String(token || '')
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Normalizes one lookup token for repeated-model grouping.
|
|
264
|
+
* @param {string | undefined} value
|
|
265
|
+
* @returns {string}
|
|
266
|
+
*/
|
|
267
|
+
static #normalizeLookupToken(value) {
|
|
268
|
+
return String(value || '')
|
|
269
|
+
.toLowerCase()
|
|
270
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves Altium TrueType text-box metadata for 3D scene consumers.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbScene3dTextBoxLayoutResolver {
|
|
9
|
+
static #COMPACT_IMPLICIT_WIDTH_RATIO = 2.25
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolves one Altium inverted text box into renderable dimensions.
|
|
13
|
+
* @param {{ isInverted?: boolean, useInvertedRectangle?: boolean, textboxRectWidth?: number, textboxRectHeight?: number, textboxRectJustification?: number, marginBorderWidth?: number, height?: number }} text
|
|
14
|
+
* @returns {{ source: string, mode: 'explicit' | 'implicit', compact: boolean, widthMil: number, heightMil: number, marginMil: number, renderWidthMil: number, renderHeightMil: number, justification: { column: number, row: number } | null } | null}
|
|
15
|
+
*/
|
|
16
|
+
static resolve(text) {
|
|
17
|
+
const width = Number(text?.textboxRectWidth)
|
|
18
|
+
const height = Number(text?.textboxRectHeight)
|
|
19
|
+
|
|
20
|
+
if (
|
|
21
|
+
!Boolean(text?.isInverted) ||
|
|
22
|
+
!Number.isFinite(width) ||
|
|
23
|
+
!Number.isFinite(height) ||
|
|
24
|
+
width <= 0 ||
|
|
25
|
+
height <= 0
|
|
26
|
+
) {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const mode = Boolean(text?.useInvertedRectangle)
|
|
31
|
+
? 'explicit'
|
|
32
|
+
: 'implicit'
|
|
33
|
+
const margin = PcbScene3dTextBoxLayoutResolver.#margin(text)
|
|
34
|
+
const compact =
|
|
35
|
+
mode === 'implicit' &&
|
|
36
|
+
width <=
|
|
37
|
+
PcbScene3dTextBoxLayoutResolver.#height(text) *
|
|
38
|
+
PcbScene3dTextBoxLayoutResolver
|
|
39
|
+
.#COMPACT_IMPLICIT_WIDTH_RATIO
|
|
40
|
+
const border = compact ? margin * 2 : 0
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
source: 'altium-textbox',
|
|
44
|
+
mode,
|
|
45
|
+
compact,
|
|
46
|
+
widthMil: width,
|
|
47
|
+
heightMil: height,
|
|
48
|
+
marginMil: margin,
|
|
49
|
+
renderWidthMil: width + border,
|
|
50
|
+
renderHeightMil: height + border,
|
|
51
|
+
justification: PcbScene3dTextBoxLayoutResolver.#justification(text)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves one non-negative margin value.
|
|
57
|
+
* @param {{ marginBorderWidth?: number }} text
|
|
58
|
+
* @returns {number}
|
|
59
|
+
*/
|
|
60
|
+
static #margin(text) {
|
|
61
|
+
const margin = Number(text?.marginBorderWidth)
|
|
62
|
+
|
|
63
|
+
return Number.isFinite(margin) && margin >= 0 ? margin : 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolves the authored text height used for compact box detection.
|
|
68
|
+
* @param {{ height?: number }} text
|
|
69
|
+
* @returns {number}
|
|
70
|
+
*/
|
|
71
|
+
static #height(text) {
|
|
72
|
+
return Math.max(Number(text?.height || 0), 1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decodes Altium's three-by-three text-box justification code.
|
|
77
|
+
* @param {{ textboxRectJustification?: number }} text
|
|
78
|
+
* @returns {{ column: number, row: number } | null}
|
|
79
|
+
*/
|
|
80
|
+
static #justification(text) {
|
|
81
|
+
const justification = Number(text?.textboxRectJustification)
|
|
82
|
+
|
|
83
|
+
if (!Number.isInteger(justification) || justification <= 0) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
column: Math.max(
|
|
89
|
+
0,
|
|
90
|
+
Math.min(2, Math.floor((justification - 1) / 3))
|
|
91
|
+
),
|
|
92
|
+
row: (justification - 1) % 3
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|