altium-toolkit 0.1.21 → 0.1.23

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
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 = PcbScene3dBuilder.#resolveExternalPlacementMountSide(
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
- PcbScene3dBuilder.#resolveBodyGroupKey(componentBody)
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
- PcbScene3dBuilder.#scoreBodyComponentAffinity(
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
- PcbScene3dBuilder.#resolveBodyGroupKey(componentBody)
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
- PcbScene3dBuilder.#scoreBodyComponentAffinity(
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
- PcbScene3dBuilder.#scoreBodyComponentAffinity(
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 = PcbScene3dBuilder.#resolveBodyGroupKey(componentBody)
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
+ }