altium-toolkit 0.1.18 → 0.1.21
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 +1 -1
- package/src/core/altium/AltiumParser.mjs +15 -3
- package/src/core/altium/PcbFontMetricsParser.mjs +33 -6
- package/src/core/altium/PcbModelParser.mjs +82 -0
- package/src/core/altium/PcbTextPrimitiveParser.mjs +48 -3
- package/src/core/altium/SchematicPinDesignatorInferer.mjs +401 -0
- package/src/core/altium/SchematicPinParser.mjs +136 -56
- package/src/core/altium/SchematicStreamExtractor.mjs +93 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +40 -12
- package/src/ui/PcbScene3dBuilder.mjs +593 -53
- package/src/ui/PcbScene3dDrillCutoutBuilder.mjs +453 -0
- package/src/ui/SchematicImageRenderer.mjs +134 -32
- package/src/ui/SchematicJunctionRenderer.mjs +22 -4
- package/src/ui/SchematicNoteRenderer.mjs +73 -9
- package/src/ui/SchematicPinSvgRenderer.mjs +25 -3
- package/src/ui/SchematicPowerPortRenderer.mjs +183 -36
- package/src/ui/SchematicSvgRenderer.mjs +2 -2
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
|
|
5
5
|
import { PcbEdgeFacingGlyphNormalizer } from './PcbEdgeFacingGlyphNormalizer.mjs'
|
|
6
|
+
import { PcbScene3dDrillCutoutBuilder } from './PcbScene3dDrillCutoutBuilder.mjs'
|
|
6
7
|
import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
|
|
7
8
|
import { PcbScene3dPackages } from './PcbScene3dPackages.mjs'
|
|
8
9
|
|
|
@@ -10,14 +11,22 @@ import { PcbScene3dPackages } from './PcbScene3dPackages.mjs'
|
|
|
10
11
|
* Builds deterministic 3D scene data from the normalized PCB model.
|
|
11
12
|
*/
|
|
12
13
|
export class PcbScene3dBuilder {
|
|
14
|
+
static #DENSE_OVERLAY_FILL_COLOR = 0xf8f6ef
|
|
15
|
+
static #DENSE_OVERLAY_MIN_REGION_AREA_RATIO = 0.2
|
|
16
|
+
static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
|
|
17
|
+
static #DENSE_OVERLAY_KNOCKOUT_COLOR = 0x2f6a2c
|
|
18
|
+
static #PRECISE_BODY_MATCH_TOLERANCE_MIL = 5
|
|
19
|
+
static #TRUETYPE_TEXT_WIDTH_RATIO = 0.55
|
|
20
|
+
|
|
13
21
|
/**
|
|
14
22
|
* Builds a scene description for host 3D renderers.
|
|
15
23
|
* @param {{ pcb?: { boardOutline?: { widthMil?: number, heightMil?: number, minX?: number, minY?: number, segments?: Array<Record<string, number | string>> }, primitiveLayers?: { layerId: number, name: string }[], pads?: { x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[], tracks?: any[], arcs?: any[], fills?: any[], vias?: any[], polygons?: any[], embeddedModels?: any[], componentBodies?: { modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number }[], components?: { designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null, source?: string, modelPath?: string }[] } }} documentModel
|
|
16
24
|
* @param {{ modelRegistry?: { resolveComponentModel: (component: any) => { name: string, relativePath: string, format: string } | null, resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null, boardThicknessMil?: number }} [options]
|
|
17
|
-
* @returns {{ board: { widthMil: number, heightMil: number, thicknessMil: number, minX: number, minY: number, centerX: number, centerY: number, segments: Array<Record<string, number | string>> }, components: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, boardPositionMil: { x: number, y: number, z: number }, pattern: string, source: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null }[], externalPlacements: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, bodyRotationDeg: number, modelTransform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number }, externalModel: { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } }[], detail: { pads: any[], tracks: any[], arcs: any[], fills: any[], vias: any[], polygons: any[], silkscreen: { top: { fills: any[], tracks: any[], arcs: any[] }, bottom: { fills: any[], tracks: any[], arcs: any[] } } } }}
|
|
25
|
+
* @returns {{ board: { widthMil: number, heightMil: number, thicknessMil: number, minX: number, minY: number, centerX: number, centerY: number, segments: Array<Record<string, number | string>> }, components: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, boardPositionMil: { x: number, y: number, z: number }, pattern: string, source: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null }[], externalPlacements: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, bodyRotationDeg: number, modelTransform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number }, externalModel: { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } }[], detail: { pads: any[], tracks: any[], arcs: any[], fills: any[], vias: any[], polygons: any[], silkscreen: { top: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number }, bottom: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number } } } }}
|
|
18
26
|
*/
|
|
19
27
|
static build(documentModel, options = {}) {
|
|
20
28
|
const pcb = documentModel?.pcb || {}
|
|
29
|
+
const appearance3d = pcb.appearance3d || {}
|
|
21
30
|
const boardOutline = pcb.boardOutline || {}
|
|
22
31
|
const primitiveLayers = Array.isArray(pcb.primitiveLayers)
|
|
23
32
|
? pcb.primitiveLayers
|
|
@@ -30,6 +39,10 @@ export class PcbScene3dBuilder {
|
|
|
30
39
|
const tracks = Array.isArray(pcb.tracks) ? pcb.tracks : []
|
|
31
40
|
const arcs = Array.isArray(pcb.arcs) ? pcb.arcs : []
|
|
32
41
|
const fills = Array.isArray(pcb.fills) ? pcb.fills : []
|
|
42
|
+
const texts = Array.isArray(pcb.texts) ? pcb.texts : []
|
|
43
|
+
const vias = Array.isArray(pcb.vias) ? pcb.vias : []
|
|
44
|
+
const silkscreenRegions =
|
|
45
|
+
PcbScene3dBuilder.#resolveSilkscreenRegions(pcb)
|
|
33
46
|
const thicknessMil = Number(options.boardThicknessMil || 63) || 63
|
|
34
47
|
const modelRegistry = options.modelRegistry || null
|
|
35
48
|
const board = {
|
|
@@ -52,8 +65,40 @@ export class PcbScene3dBuilder {
|
|
|
52
65
|
componentBodies,
|
|
53
66
|
components
|
|
54
67
|
)
|
|
68
|
+
const topSilkscreen = PcbScene3dBuilder.#buildSilkscreenSide(
|
|
69
|
+
primitiveLayers,
|
|
70
|
+
fills,
|
|
71
|
+
tracks,
|
|
72
|
+
arcs,
|
|
73
|
+
texts,
|
|
74
|
+
silkscreenRegions,
|
|
75
|
+
boardOutline,
|
|
76
|
+
'top',
|
|
77
|
+
pads,
|
|
78
|
+
vias
|
|
79
|
+
)
|
|
80
|
+
const bottomSilkscreen = PcbScene3dBuilder.#buildSilkscreenSide(
|
|
81
|
+
primitiveLayers,
|
|
82
|
+
fills,
|
|
83
|
+
tracks,
|
|
84
|
+
arcs,
|
|
85
|
+
texts,
|
|
86
|
+
silkscreenRegions,
|
|
87
|
+
boardOutline,
|
|
88
|
+
'bottom',
|
|
89
|
+
pads,
|
|
90
|
+
vias
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
PcbScene3dBuilder.#applySilkscreenAppearance(
|
|
94
|
+
topSilkscreen,
|
|
95
|
+
bottomSilkscreen,
|
|
96
|
+
board,
|
|
97
|
+
appearance3d
|
|
98
|
+
)
|
|
55
99
|
|
|
56
100
|
return {
|
|
101
|
+
sourceFormat: 'altium',
|
|
57
102
|
board,
|
|
58
103
|
components: components.map((component) =>
|
|
59
104
|
PcbScene3dBuilder.#buildComponent(
|
|
@@ -76,33 +121,18 @@ export class PcbScene3dBuilder {
|
|
|
76
121
|
)
|
|
77
122
|
.filter(Boolean),
|
|
78
123
|
detail: {
|
|
124
|
+
embeddedFonts: Array.isArray(pcb.embeddedFonts)
|
|
125
|
+
? pcb.embeddedFonts
|
|
126
|
+
: [],
|
|
79
127
|
pads,
|
|
80
128
|
tracks,
|
|
81
129
|
arcs,
|
|
82
130
|
fills,
|
|
83
|
-
vias
|
|
131
|
+
vias,
|
|
84
132
|
polygons: Array.isArray(pcb.polygons) ? pcb.polygons : [],
|
|
85
133
|
silkscreen: {
|
|
86
|
-
top:
|
|
87
|
-
|
|
88
|
-
primitiveLayers,
|
|
89
|
-
fills,
|
|
90
|
-
tracks,
|
|
91
|
-
arcs,
|
|
92
|
-
'top'
|
|
93
|
-
),
|
|
94
|
-
boardOutline
|
|
95
|
-
),
|
|
96
|
-
bottom: PcbEdgeFacingGlyphNormalizer.normalize(
|
|
97
|
-
PcbFootprintPrimitiveSelector.select(
|
|
98
|
-
primitiveLayers,
|
|
99
|
-
fills,
|
|
100
|
-
tracks,
|
|
101
|
-
arcs,
|
|
102
|
-
'bottom'
|
|
103
|
-
),
|
|
104
|
-
boardOutline
|
|
105
|
-
)
|
|
134
|
+
top: topSilkscreen,
|
|
135
|
+
bottom: bottomSilkscreen
|
|
106
136
|
}
|
|
107
137
|
}
|
|
108
138
|
}
|
|
@@ -163,7 +193,7 @@ export class PcbScene3dBuilder {
|
|
|
163
193
|
/**
|
|
164
194
|
* Builds one explicit external-model placement from normalized component
|
|
165
195
|
* body metadata.
|
|
166
|
-
* @param {{ modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number }} componentBody
|
|
196
|
+
* @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
|
|
167
197
|
* @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent
|
|
168
198
|
* @param {{ centerX: number, centerY: number }} board
|
|
169
199
|
* @param {number} thicknessMil
|
|
@@ -190,16 +220,17 @@ export class PcbScene3dBuilder {
|
|
|
190
220
|
return null
|
|
191
221
|
}
|
|
192
222
|
|
|
193
|
-
const mountSide =
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
223
|
+
const mountSide = PcbScene3dBuilder.#resolveExternalPlacementMountSide(
|
|
224
|
+
componentBody,
|
|
225
|
+
matchedComponent
|
|
226
|
+
)
|
|
197
227
|
const halfBoardThickness = thicknessMil / 2
|
|
198
228
|
const sourcePosition =
|
|
199
229
|
PcbScene3dBuilder.#resolveExternalPlacementSourcePosition(
|
|
200
|
-
componentBody
|
|
201
|
-
matchedComponent
|
|
230
|
+
componentBody
|
|
202
231
|
)
|
|
232
|
+
const modelRotation =
|
|
233
|
+
PcbScene3dBuilder.#resolveExternalModelRotation(componentBody)
|
|
203
234
|
|
|
204
235
|
return {
|
|
205
236
|
designator:
|
|
@@ -226,11 +257,7 @@ export class PcbScene3dBuilder {
|
|
|
226
257
|
},
|
|
227
258
|
bodyRotationDeg: Number(componentBody.rotationDeg || 0),
|
|
228
259
|
modelTransform: {
|
|
229
|
-
rotationDeg:
|
|
230
|
-
x: Number(componentBody.modelRotationDeg?.x || 0),
|
|
231
|
-
y: Number(componentBody.modelRotationDeg?.y || 0),
|
|
232
|
-
z: Number(componentBody.modelRotationDeg?.z || 0)
|
|
233
|
-
},
|
|
260
|
+
rotationDeg: modelRotation,
|
|
234
261
|
dzMil: Number(componentBody.dzMil || 0)
|
|
235
262
|
},
|
|
236
263
|
externalModel: resolvedModel
|
|
@@ -251,6 +278,10 @@ export class PcbScene3dBuilder {
|
|
|
251
278
|
const assignedBodyIndexes = new Set()
|
|
252
279
|
const assignedComponentIndexes = new Set()
|
|
253
280
|
const closeCandidates = []
|
|
281
|
+
const matchContext = PcbScene3dBuilder.#buildBodyMatchContext(
|
|
282
|
+
componentBodies,
|
|
283
|
+
components
|
|
284
|
+
)
|
|
254
285
|
|
|
255
286
|
componentBodies.forEach((componentBody, bodyIndex) => {
|
|
256
287
|
components.forEach((component, componentIndex) => {
|
|
@@ -260,7 +291,15 @@ export class PcbScene3dBuilder {
|
|
|
260
291
|
component
|
|
261
292
|
)
|
|
262
293
|
|
|
263
|
-
if (
|
|
294
|
+
if (
|
|
295
|
+
distance <= 600 &&
|
|
296
|
+
PcbScene3dBuilder.#canUseCloseBodyComponentMatch(
|
|
297
|
+
componentBody,
|
|
298
|
+
component,
|
|
299
|
+
matchContext,
|
|
300
|
+
distance
|
|
301
|
+
)
|
|
302
|
+
) {
|
|
264
303
|
closeCandidates.push({
|
|
265
304
|
bodyIndex,
|
|
266
305
|
componentIndex,
|
|
@@ -349,6 +388,84 @@ export class PcbScene3dBuilder {
|
|
|
349
388
|
return matches
|
|
350
389
|
}
|
|
351
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Builds reusable identity statistics for body/component matching.
|
|
393
|
+
* @param {{ modelId?: string, name?: string, identifier?: string }[]} componentBodies
|
|
394
|
+
* @param {{ pattern?: string, source?: string, modelPath?: string }[]} components
|
|
395
|
+
* @returns {{ bodyGroupCounts: Map<string, number>, candidateComponentCounts: Map<string, number> }}
|
|
396
|
+
*/
|
|
397
|
+
static #buildBodyMatchContext(componentBodies, components) {
|
|
398
|
+
const bodyGroupCounts = new Map()
|
|
399
|
+
const bodyByGroup = new Map()
|
|
400
|
+
const candidateComponentCounts = new Map()
|
|
401
|
+
|
|
402
|
+
for (const componentBody of componentBodies) {
|
|
403
|
+
const groupKey =
|
|
404
|
+
PcbScene3dBuilder.#resolveBodyGroupKey(componentBody)
|
|
405
|
+
bodyGroupCounts.set(
|
|
406
|
+
groupKey,
|
|
407
|
+
(bodyGroupCounts.get(groupKey) || 0) + 1
|
|
408
|
+
)
|
|
409
|
+
if (!bodyByGroup.has(groupKey)) {
|
|
410
|
+
bodyByGroup.set(groupKey, componentBody)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
bodyByGroup.forEach((componentBody, groupKey) => {
|
|
415
|
+
candidateComponentCounts.set(
|
|
416
|
+
groupKey,
|
|
417
|
+
components.filter(
|
|
418
|
+
(component) =>
|
|
419
|
+
PcbScene3dBuilder.#scoreBodyComponentAffinity(
|
|
420
|
+
componentBody,
|
|
421
|
+
component
|
|
422
|
+
) > 0
|
|
423
|
+
).length
|
|
424
|
+
)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
return { bodyGroupCounts, candidateComponentCounts }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Returns true when a close body/component pair is identity-compatible and
|
|
432
|
+
* the body group can be matched one-to-one to component anchors.
|
|
433
|
+
* @param {{ modelId?: string, name?: string, identifier?: string }} componentBody
|
|
434
|
+
* @param {{ pattern?: string, source?: string, modelPath?: string }} component
|
|
435
|
+
* @param {{ bodyGroupCounts: Map<string, number>, candidateComponentCounts: Map<string, number> }} matchContext
|
|
436
|
+
* @param {number} distanceMil Distance between body and component anchors.
|
|
437
|
+
* @returns {boolean}
|
|
438
|
+
*/
|
|
439
|
+
static #canUseCloseBodyComponentMatch(
|
|
440
|
+
componentBody,
|
|
441
|
+
component,
|
|
442
|
+
matchContext,
|
|
443
|
+
distanceMil
|
|
444
|
+
) {
|
|
445
|
+
if (
|
|
446
|
+
Number(distanceMil) <=
|
|
447
|
+
PcbScene3dBuilder.#PRECISE_BODY_MATCH_TOLERANCE_MIL
|
|
448
|
+
) {
|
|
449
|
+
return true
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (
|
|
453
|
+
PcbScene3dBuilder.#scoreBodyComponentAffinity(
|
|
454
|
+
componentBody,
|
|
455
|
+
component
|
|
456
|
+
) <= 0
|
|
457
|
+
) {
|
|
458
|
+
return false
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const groupKey = PcbScene3dBuilder.#resolveBodyGroupKey(componentBody)
|
|
462
|
+
const bodyCount = matchContext.bodyGroupCounts.get(groupKey) || 0
|
|
463
|
+
const candidateCount =
|
|
464
|
+
matchContext.candidateComponentCounts.get(groupKey) || 0
|
|
465
|
+
|
|
466
|
+
return bodyCount > 0 && bodyCount <= candidateCount
|
|
467
|
+
}
|
|
468
|
+
|
|
352
469
|
/**
|
|
353
470
|
* Pairs one unresolved repeated body group with a repeated component group
|
|
354
471
|
* by preserving the dominant ordering axis and choosing the pairing that
|
|
@@ -506,23 +623,12 @@ export class PcbScene3dBuilder {
|
|
|
506
623
|
}
|
|
507
624
|
|
|
508
625
|
/**
|
|
509
|
-
* Returns the
|
|
626
|
+
* Returns the native body anchor that should be used for one explicit model
|
|
510
627
|
* placement.
|
|
511
628
|
* @param {{ positionMil?: { x?: number, y?: number } }} componentBody
|
|
512
|
-
* @param {{ x: number, y: number } | null} matchedComponent
|
|
513
629
|
* @returns {{ x: number, y: number }}
|
|
514
630
|
*/
|
|
515
|
-
static #resolveExternalPlacementSourcePosition(
|
|
516
|
-
componentBody,
|
|
517
|
-
matchedComponent
|
|
518
|
-
) {
|
|
519
|
-
if (matchedComponent) {
|
|
520
|
-
return {
|
|
521
|
-
x: Number(matchedComponent.x || 0),
|
|
522
|
-
y: Number(matchedComponent.y || 0)
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
631
|
+
static #resolveExternalPlacementSourcePosition(componentBody) {
|
|
526
632
|
return {
|
|
527
633
|
x: Number(componentBody?.positionMil?.x || 0),
|
|
528
634
|
y: Number(componentBody?.positionMil?.y || 0)
|
|
@@ -530,20 +636,454 @@ export class PcbScene3dBuilder {
|
|
|
530
636
|
}
|
|
531
637
|
|
|
532
638
|
/**
|
|
533
|
-
* Resolves
|
|
534
|
-
*
|
|
535
|
-
*
|
|
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
|
+
/**
|
|
673
|
+
* Resolves the authored placement rotation for one explicit external model.
|
|
674
|
+
* Altium stores the 3D model's board-facing yaw in MODEL.3D.ROTZ.
|
|
536
675
|
* @param {{ rotationDeg?: number }} componentBody
|
|
537
676
|
* @param {{ rotation?: number } | null} matchedComponent
|
|
538
677
|
* @returns {number}
|
|
539
678
|
*/
|
|
540
679
|
static #resolveExternalPlacementRotation(componentBody, matchedComponent) {
|
|
680
|
+
const modelRotationZ = Number(componentBody?.modelRotationDeg?.z)
|
|
681
|
+
if (Number.isFinite(modelRotationZ)) {
|
|
682
|
+
return PcbScene3dBuilder.#normalizeAngle(modelRotationZ)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return PcbScene3dBuilder.#normalizeAngle(
|
|
686
|
+
Number(componentBody?.rotationDeg || 0) +
|
|
687
|
+
Number(matchedComponent?.rotation || 0)
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Resolves model-local rotations after converting Altium's positive local
|
|
693
|
+
* rotation fields into the renderer's signed 3D model convention.
|
|
694
|
+
* @param {{ modelRotationDeg?: { x?: number, y?: number, z?: number } }} componentBody
|
|
695
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
696
|
+
*/
|
|
697
|
+
static #resolveExternalModelRotation(componentBody) {
|
|
698
|
+
return {
|
|
699
|
+
x: -Number(componentBody?.modelRotationDeg?.x || 0),
|
|
700
|
+
y: -Number(componentBody?.modelRotationDeg?.y || 0),
|
|
701
|
+
z: 0
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Selects and normalizes one board-side silkscreen primitive set.
|
|
707
|
+
* @param {{ layerId: number, name: string }[]} primitiveLayers
|
|
708
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[]} fills
|
|
709
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[]} tracks
|
|
710
|
+
* @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[]} arcs
|
|
711
|
+
* @param {{ text?: string, value?: string, x?: number, y?: number, height?: number, strokeWidth?: number, layerCode?: number, layerId?: number, visible?: boolean }[]} texts
|
|
712
|
+
* @param {{ points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[]} regions
|
|
713
|
+
* @param {{ minX?: number, minY?: number, widthMil?: number, heightMil?: number }} boardOutline
|
|
714
|
+
* @param {'top' | 'bottom'} side
|
|
715
|
+
* @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
|
|
716
|
+
* @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
|
|
717
|
+
* @returns {{ fills: object[], tracks: object[], arcs: object[], regions: object[], texts: object[], nativeTextKnockouts: boolean }}
|
|
718
|
+
*/
|
|
719
|
+
static #buildSilkscreenSide(
|
|
720
|
+
primitiveLayers,
|
|
721
|
+
fills,
|
|
722
|
+
tracks,
|
|
723
|
+
arcs,
|
|
724
|
+
texts,
|
|
725
|
+
regions,
|
|
726
|
+
boardOutline,
|
|
727
|
+
side,
|
|
728
|
+
pads,
|
|
729
|
+
vias
|
|
730
|
+
) {
|
|
731
|
+
const normalized = PcbEdgeFacingGlyphNormalizer.normalize(
|
|
732
|
+
PcbFootprintPrimitiveSelector.select(
|
|
733
|
+
primitiveLayers,
|
|
734
|
+
fills,
|
|
735
|
+
tracks,
|
|
736
|
+
arcs,
|
|
737
|
+
regions,
|
|
738
|
+
side
|
|
739
|
+
),
|
|
740
|
+
boardOutline
|
|
741
|
+
)
|
|
742
|
+
const fillsWithRegions = [
|
|
743
|
+
...(normalized.fills || []),
|
|
744
|
+
...(normalized.regions || [])
|
|
745
|
+
]
|
|
746
|
+
const denseOverlayArtwork = PcbScene3dBuilder.#isDenseOverlayArtwork(
|
|
747
|
+
{
|
|
748
|
+
fills: fillsWithRegions,
|
|
749
|
+
tracks: normalized.tracks,
|
|
750
|
+
arcs: normalized.arcs
|
|
751
|
+
},
|
|
752
|
+
boardOutline
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
...normalized,
|
|
757
|
+
denseOverlayArtwork,
|
|
758
|
+
nativeTextKnockouts: PcbScene3dBuilder.#hasNativeTextKnockouts(
|
|
759
|
+
fillsWithRegions,
|
|
760
|
+
normalized,
|
|
761
|
+
boardOutline
|
|
762
|
+
),
|
|
763
|
+
texts: PcbScene3dBuilder.#selectSilkscreenTexts(
|
|
764
|
+
primitiveLayers,
|
|
765
|
+
texts,
|
|
766
|
+
side
|
|
767
|
+
),
|
|
768
|
+
tracks: normalized.tracks,
|
|
769
|
+
fills: PcbScene3dDrillCutoutBuilder.clipFills(
|
|
770
|
+
fillsWithRegions,
|
|
771
|
+
pads,
|
|
772
|
+
vias
|
|
773
|
+
)
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Applies optional appearance hints for overlay artwork that carries broad
|
|
779
|
+
* silkscreen graphics plus dense board-colored linework.
|
|
780
|
+
* @param {{ fills?: any[], tracks?: any[], arcs?: any[], fillColor?: number, strokeColor?: number }} topSilkscreen
|
|
781
|
+
* @param {{ fills?: any[], tracks?: any[], arcs?: any[], fillColor?: number, strokeColor?: number }} bottomSilkscreen
|
|
782
|
+
* @param {{ widthMil?: number, heightMil?: number }} board
|
|
783
|
+
* @param {{ silkscreenTopColor?: number, silkscreenBottomColor?: number }} appearance3d
|
|
784
|
+
* @returns {void}
|
|
785
|
+
*/
|
|
786
|
+
static #applySilkscreenAppearance(
|
|
787
|
+
topSilkscreen,
|
|
788
|
+
bottomSilkscreen,
|
|
789
|
+
board,
|
|
790
|
+
appearance3d
|
|
791
|
+
) {
|
|
792
|
+
PcbScene3dBuilder.#styleSilkscreenArtwork(
|
|
793
|
+
topSilkscreen,
|
|
794
|
+
board,
|
|
795
|
+
appearance3d.silkscreenTopColor
|
|
796
|
+
)
|
|
797
|
+
PcbScene3dBuilder.#styleSilkscreenArtwork(
|
|
798
|
+
bottomSilkscreen,
|
|
799
|
+
board,
|
|
800
|
+
appearance3d.silkscreenBottomColor
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Applies silkscreen colors and marks dense overlay art as light filled
|
|
806
|
+
* areas with app-board-colored strokes.
|
|
807
|
+
* @param {{ fills?: any[], tracks?: any[], arcs?: any[], fillColor?: number, strokeColor?: number, knockoutColor?: number, denseOverlayArtwork?: boolean }} side
|
|
808
|
+
* @param {{ widthMil?: number, heightMil?: number }} board
|
|
809
|
+
* @param {number | undefined} silkscreenColor
|
|
810
|
+
* @returns {void}
|
|
811
|
+
*/
|
|
812
|
+
static #styleSilkscreenArtwork(side, board, silkscreenColor) {
|
|
813
|
+
if (Number.isInteger(silkscreenColor)) {
|
|
814
|
+
side.strokeColor = silkscreenColor
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (
|
|
818
|
+
!side?.denseOverlayArtwork &&
|
|
819
|
+
!PcbScene3dBuilder.#isDenseOverlayArtwork(side, board)
|
|
820
|
+
) {
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
side.fillColor = Number.isInteger(silkscreenColor)
|
|
825
|
+
? silkscreenColor
|
|
826
|
+
: PcbScene3dBuilder.#DENSE_OVERLAY_FILL_COLOR
|
|
827
|
+
side.strokeColor = side.fillColor
|
|
828
|
+
side.knockoutColor = PcbScene3dBuilder.#DENSE_OVERLAY_KNOCKOUT_COLOR
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Selects visible side-specific silkscreen texts.
|
|
833
|
+
* @param {{ layerId: number, name: string }[]} primitiveLayers
|
|
834
|
+
* @param {{ text?: string, value?: string, x?: number, y?: number, height?: number, strokeWidth?: number, layerCode?: number, layerId?: number, visible?: boolean }[]} texts
|
|
835
|
+
* @param {'top' | 'bottom'} side
|
|
836
|
+
* @returns {object[]}
|
|
837
|
+
*/
|
|
838
|
+
static #selectSilkscreenTexts(primitiveLayers, texts, side) {
|
|
839
|
+
const layerIds = PcbScene3dBuilder.#resolveSilkscreenLayerIds(
|
|
840
|
+
primitiveLayers,
|
|
841
|
+
side
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
return (Array.isArray(texts) ? texts : [])
|
|
845
|
+
.filter((text) => text?.visible !== false)
|
|
846
|
+
.filter((text) => layerIds.has(Number(text?.layerId)))
|
|
847
|
+
.map((text) =>
|
|
848
|
+
PcbScene3dBuilder.#normalizeSilkscreenText(text, side)
|
|
849
|
+
)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Resolves layer IDs that belong to one overlay side.
|
|
854
|
+
* @param {{ layerId: number, name: string }[]} primitiveLayers
|
|
855
|
+
* @param {'top' | 'bottom'} side
|
|
856
|
+
* @returns {Set<number>}
|
|
857
|
+
*/
|
|
858
|
+
static #resolveSilkscreenLayerIds(primitiveLayers, side) {
|
|
859
|
+
const needle = side === 'bottom' ? 'BOTTOM OVERLAY' : 'TOP OVERLAY'
|
|
860
|
+
|
|
861
|
+
return new Set(
|
|
862
|
+
(Array.isArray(primitiveLayers) ? primitiveLayers : [])
|
|
863
|
+
.filter((layer) =>
|
|
864
|
+
String(layer?.name || '')
|
|
865
|
+
.trim()
|
|
866
|
+
.toUpperCase()
|
|
867
|
+
.includes(needle)
|
|
868
|
+
)
|
|
869
|
+
.map((layer) => Number(layer.layerId))
|
|
870
|
+
.filter((layerId) => Number.isInteger(layerId))
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Normalizes one Altium overlay text into the runtime stroke-text shape.
|
|
876
|
+
* @param {{ text?: string, value?: string, x?: number, y?: number, height?: number, strokeWidth?: number, rotation?: number, mirrored?: boolean | number | string, isMirrored?: boolean | number | string, mirrorFlag?: boolean | number | string, Mirrored?: boolean | number | string, IsMirrored?: boolean | number | string, MirrorFlag?: boolean | number | string, layerId?: number }} text
|
|
877
|
+
* @param {'top' | 'bottom'} side
|
|
878
|
+
* @returns {object}
|
|
879
|
+
*/
|
|
880
|
+
static #normalizeSilkscreenText(text, side) {
|
|
881
|
+
const height = Math.max(Number(text?.height || 0), 1)
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
...text,
|
|
885
|
+
text: String(text?.text ?? text?.value ?? ''),
|
|
886
|
+
value: String(text?.text ?? text?.value ?? ''),
|
|
887
|
+
sizeX: height,
|
|
888
|
+
sizeY:
|
|
889
|
+
height *
|
|
890
|
+
PcbScene3dBuilder.#resolveSilkscreenTextWidthRatio(text),
|
|
891
|
+
thickness: Math.max(Number(text?.strokeWidth || 0), 1),
|
|
892
|
+
hAlign: 'left',
|
|
893
|
+
vAlign: 'bottom',
|
|
894
|
+
mirrored: PcbScene3dBuilder.#resolveSilkscreenTextMirrored(text),
|
|
895
|
+
side,
|
|
896
|
+
rotation: PcbScene3dBuilder.#resolveSilkscreenTextRotation(text)
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Converts screen-space Altium text rotation for the shared 3D text factories.
|
|
902
|
+
* @param {{ rotation?: number | string }} text
|
|
903
|
+
* @returns {number}
|
|
904
|
+
*/
|
|
905
|
+
static #resolveSilkscreenTextRotation(text) {
|
|
541
906
|
return PcbScene3dBuilder.#normalizeAngle(
|
|
542
|
-
Number(
|
|
543
|
-
Number(componentBody?.rotationDeg || 0)
|
|
907
|
+
360 - Number(text?.rotation || 0)
|
|
544
908
|
)
|
|
545
909
|
}
|
|
546
910
|
|
|
911
|
+
/**
|
|
912
|
+
* Checks whether one silkscreen text primitive uses TrueType glyphs.
|
|
913
|
+
* @param {{ fontTypeName?: string, fontType?: number | string, isTrueType?: boolean }} text
|
|
914
|
+
* @returns {boolean}
|
|
915
|
+
*/
|
|
916
|
+
static #isTrueTypeSilkscreenText(text) {
|
|
917
|
+
const fontTypeName = String(text?.fontTypeName || '').toUpperCase()
|
|
918
|
+
|
|
919
|
+
return (
|
|
920
|
+
text?.isTrueType === true ||
|
|
921
|
+
Number(text?.fontType) === 1 ||
|
|
922
|
+
fontTypeName.includes('TRUETYPE')
|
|
923
|
+
)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Resolves the horizontal glyph scale used by the 3D stroke approximation.
|
|
928
|
+
* @param {{ fontTypeName?: string, fontType?: number | string, isTrueType?: boolean }} text
|
|
929
|
+
* @returns {number}
|
|
930
|
+
*/
|
|
931
|
+
static #resolveSilkscreenTextWidthRatio(text) {
|
|
932
|
+
return PcbScene3dBuilder.#isTrueTypeSilkscreenText(text)
|
|
933
|
+
? PcbScene3dBuilder.#TRUETYPE_TEXT_WIDTH_RATIO
|
|
934
|
+
: 1
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Resolves Altium's explicit per-text mirror flag.
|
|
939
|
+
* @param {{ mirrored?: boolean | number | string, isMirrored?: boolean | number | string, mirrorFlag?: boolean | number | string, Mirrored?: boolean | number | string, IsMirrored?: boolean | number | string, MirrorFlag?: boolean | number | string }} text
|
|
940
|
+
* @returns {boolean}
|
|
941
|
+
*/
|
|
942
|
+
static #resolveSilkscreenTextMirrored(text) {
|
|
943
|
+
const value =
|
|
944
|
+
text?.mirrored ??
|
|
945
|
+
text?.isMirrored ??
|
|
946
|
+
text?.mirrorFlag ??
|
|
947
|
+
text?.Mirrored ??
|
|
948
|
+
text?.IsMirrored ??
|
|
949
|
+
text?.MirrorFlag
|
|
950
|
+
|
|
951
|
+
if (typeof value === 'boolean') return value
|
|
952
|
+
if (typeof value === 'number') return value !== 0
|
|
953
|
+
|
|
954
|
+
return /^(1|true|yes|y)$/iu.test(String(value ?? '').trim())
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Detects dense Altium overlay regions that already carry text knockouts
|
|
959
|
+
* as native fill holes.
|
|
960
|
+
* @param {any[]} fills
|
|
961
|
+
* @param {{ tracks?: any[], arcs?: any[] }} primitives
|
|
962
|
+
* @param {{ widthMil?: number, heightMil?: number }} board
|
|
963
|
+
* @returns {boolean}
|
|
964
|
+
*/
|
|
965
|
+
static #hasNativeTextKnockouts(fills, primitives, board) {
|
|
966
|
+
return (
|
|
967
|
+
(Boolean(primitives?.denseOverlayArtwork) ||
|
|
968
|
+
PcbScene3dBuilder.#isDenseOverlayArtwork(
|
|
969
|
+
{
|
|
970
|
+
fills,
|
|
971
|
+
tracks: primitives?.tracks,
|
|
972
|
+
arcs: primitives?.arcs
|
|
973
|
+
},
|
|
974
|
+
board
|
|
975
|
+
)) &&
|
|
976
|
+
(Array.isArray(fills) ? fills : []).some(
|
|
977
|
+
(fill) => Array.isArray(fill?.holes) && fill.holes.length > 0
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Detects overlay art from structural density rather than file-specific
|
|
984
|
+
* labels or source identifiers.
|
|
985
|
+
* @param {{ fills?: any[], tracks?: any[], arcs?: any[] }} side
|
|
986
|
+
* @param {{ widthMil?: number, heightMil?: number }} board
|
|
987
|
+
* @returns {boolean}
|
|
988
|
+
*/
|
|
989
|
+
static #isDenseOverlayArtwork(side, board) {
|
|
990
|
+
const strokeCount =
|
|
991
|
+
(Array.isArray(side?.tracks) ? side.tracks.length : 0) +
|
|
992
|
+
(Array.isArray(side?.arcs) ? side.arcs.length : 0)
|
|
993
|
+
|
|
994
|
+
return (
|
|
995
|
+
strokeCount >= PcbScene3dBuilder.#DENSE_OVERLAY_MIN_TRACK_COUNT &&
|
|
996
|
+
PcbScene3dBuilder.#maxFillAreaRatio(side?.fills, board) >=
|
|
997
|
+
PcbScene3dBuilder.#DENSE_OVERLAY_MIN_REGION_AREA_RATIO
|
|
998
|
+
)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Resolves the largest fill-to-board bounding-box area ratio.
|
|
1003
|
+
* @param {any[] | undefined} fills
|
|
1004
|
+
* @param {{ widthMil?: number, heightMil?: number }} board
|
|
1005
|
+
* @returns {number}
|
|
1006
|
+
*/
|
|
1007
|
+
static #maxFillAreaRatio(fills, board) {
|
|
1008
|
+
const boardArea =
|
|
1009
|
+
Math.max(Number(board?.widthMil || 0), 0) *
|
|
1010
|
+
Math.max(Number(board?.heightMil || 0), 0)
|
|
1011
|
+
if (!boardArea) {
|
|
1012
|
+
return 0
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
return (Array.isArray(fills) ? fills : []).reduce((maxRatio, fill) => {
|
|
1016
|
+
const bounds = PcbScene3dBuilder.#resolveFillBounds(fill)
|
|
1017
|
+
if (!bounds) {
|
|
1018
|
+
return maxRatio
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const fillArea =
|
|
1022
|
+
Math.max(bounds.maxX - bounds.minX, 0) *
|
|
1023
|
+
Math.max(bounds.maxY - bounds.minY, 0)
|
|
1024
|
+
|
|
1025
|
+
return Math.max(maxRatio, fillArea / boardArea)
|
|
1026
|
+
}, 0)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Resolves rough authored bounds for one rectangular or polygon fill.
|
|
1031
|
+
* @param {{ x1?: number, y1?: number, x2?: number, y2?: number, points?: { x?: number, y?: number }[] }} fill
|
|
1032
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
1033
|
+
*/
|
|
1034
|
+
static #resolveFillBounds(fill) {
|
|
1035
|
+
const points = Array.isArray(fill?.points)
|
|
1036
|
+
? fill.points
|
|
1037
|
+
.map((point) => ({
|
|
1038
|
+
x: Number(point?.x),
|
|
1039
|
+
y: Number(point?.y)
|
|
1040
|
+
}))
|
|
1041
|
+
.filter(
|
|
1042
|
+
(point) =>
|
|
1043
|
+
Number.isFinite(point.x) && Number.isFinite(point.y)
|
|
1044
|
+
)
|
|
1045
|
+
: [
|
|
1046
|
+
{ x: Number(fill?.x1), y: Number(fill?.y1) },
|
|
1047
|
+
{ x: Number(fill?.x2), y: Number(fill?.y2) }
|
|
1048
|
+
].filter(
|
|
1049
|
+
(point) =>
|
|
1050
|
+
Number.isFinite(point.x) && Number.isFinite(point.y)
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
if (points.length < 2) {
|
|
1054
|
+
return null
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const xs = points.map((point) => point.x)
|
|
1058
|
+
const ys = points.map((point) => point.y)
|
|
1059
|
+
|
|
1060
|
+
return {
|
|
1061
|
+
minX: Math.min(...xs),
|
|
1062
|
+
minY: Math.min(...ys),
|
|
1063
|
+
maxX: Math.max(...xs),
|
|
1064
|
+
maxY: Math.max(...ys)
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Resolves region primitives that can contribute filled silkscreen artwork.
|
|
1070
|
+
* @param {{ regions?: object[], shapeBasedRegions?: object[] }} pcb
|
|
1071
|
+
* @returns {object[]}
|
|
1072
|
+
*/
|
|
1073
|
+
static #resolveSilkscreenRegions(pcb) {
|
|
1074
|
+
if (
|
|
1075
|
+
Array.isArray(pcb?.shapeBasedRegions) &&
|
|
1076
|
+
pcb.shapeBasedRegions.length
|
|
1077
|
+
) {
|
|
1078
|
+
return pcb.shapeBasedRegions.map((region) => ({
|
|
1079
|
+
...region,
|
|
1080
|
+
isShapeBased: true
|
|
1081
|
+
}))
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return Array.isArray(pcb?.regions) ? pcb.regions : []
|
|
1085
|
+
}
|
|
1086
|
+
|
|
547
1087
|
/**
|
|
548
1088
|
* Resolves a rough pad-span box around one component.
|
|
549
1089
|
* @param {{ x: number, y: number }} component
|