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.
@@ -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: Array.isArray(pcb.vias) ? pcb.vias : [],
131
+ vias,
84
132
  polygons: Array.isArray(pcb.polygons) ? pcb.polygons : [],
85
133
  silkscreen: {
86
- top: PcbEdgeFacingGlyphNormalizer.normalize(
87
- PcbFootprintPrimitiveSelector.select(
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
- String(matchedComponent?.layer || 'TOP').toUpperCase() === 'BOTTOM'
195
- ? 'bottom'
196
- : 'top'
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 (distance <= 600) {
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 component anchor that should be used for one resolved body
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 the authored placement rotation for one explicit external
534
- * model, combining the matched component orientation with any additional
535
- * 2D model rotation offset carried by the body metadata.
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(matchedComponent?.rotation || 0) +
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