altium-toolkit 0.1.20 → 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.
@@ -3,21 +3,32 @@
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'
9
+ import { PcbScene3dPlacementSideResolver } from './PcbScene3dPlacementSideResolver.mjs'
10
+ import { PcbScene3dTextBoxLayoutResolver } from './PcbScene3dTextBoxLayoutResolver.mjs'
8
11
 
9
12
  /**
10
13
  * Builds deterministic 3D scene data from the normalized PCB model.
11
14
  */
12
15
  export class PcbScene3dBuilder {
16
+ static #DENSE_OVERLAY_FILL_COLOR = 0xf8f6ef
17
+ static #DENSE_OVERLAY_MIN_REGION_AREA_RATIO = 0.2
18
+ static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
19
+ static #DENSE_OVERLAY_KNOCKOUT_COLOR = 0x2f6a2c
20
+ static #PRECISE_BODY_MATCH_TOLERANCE_MIL = 5
21
+ static #TRUETYPE_TEXT_WIDTH_RATIO = 0.55
22
+
13
23
  /**
14
24
  * Builds a scene description for host 3D renderers.
15
25
  * @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
26
  * @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[] } } } }}
27
+ * @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
28
  */
19
29
  static build(documentModel, options = {}) {
20
30
  const pcb = documentModel?.pcb || {}
31
+ const appearance3d = pcb.appearance3d || {}
21
32
  const boardOutline = pcb.boardOutline || {}
22
33
  const primitiveLayers = Array.isArray(pcb.primitiveLayers)
23
34
  ? pcb.primitiveLayers
@@ -30,6 +41,10 @@ export class PcbScene3dBuilder {
30
41
  const tracks = Array.isArray(pcb.tracks) ? pcb.tracks : []
31
42
  const arcs = Array.isArray(pcb.arcs) ? pcb.arcs : []
32
43
  const fills = Array.isArray(pcb.fills) ? pcb.fills : []
44
+ const texts = Array.isArray(pcb.texts) ? pcb.texts : []
45
+ const vias = Array.isArray(pcb.vias) ? pcb.vias : []
46
+ const silkscreenRegions =
47
+ PcbScene3dBuilder.#resolveSilkscreenRegions(pcb)
33
48
  const thicknessMil = Number(options.boardThicknessMil || 63) || 63
34
49
  const modelRegistry = options.modelRegistry || null
35
50
  const board = {
@@ -52,8 +67,40 @@ export class PcbScene3dBuilder {
52
67
  componentBodies,
53
68
  components
54
69
  )
70
+ const topSilkscreen = PcbScene3dBuilder.#buildSilkscreenSide(
71
+ primitiveLayers,
72
+ fills,
73
+ tracks,
74
+ arcs,
75
+ texts,
76
+ silkscreenRegions,
77
+ boardOutline,
78
+ 'top',
79
+ pads,
80
+ vias
81
+ )
82
+ const bottomSilkscreen = PcbScene3dBuilder.#buildSilkscreenSide(
83
+ primitiveLayers,
84
+ fills,
85
+ tracks,
86
+ arcs,
87
+ texts,
88
+ silkscreenRegions,
89
+ boardOutline,
90
+ 'bottom',
91
+ pads,
92
+ vias
93
+ )
94
+
95
+ PcbScene3dBuilder.#applySilkscreenAppearance(
96
+ topSilkscreen,
97
+ bottomSilkscreen,
98
+ board,
99
+ appearance3d
100
+ )
55
101
 
56
102
  return {
103
+ sourceFormat: 'altium',
57
104
  board,
58
105
  components: components.map((component) =>
59
106
  PcbScene3dBuilder.#buildComponent(
@@ -69,6 +116,7 @@ export class PcbScene3dBuilder {
69
116
  PcbScene3dBuilder.#buildExternalPlacement(
70
117
  componentBody,
71
118
  bodyMatches[index],
119
+ components,
72
120
  board,
73
121
  thicknessMil,
74
122
  modelRegistry
@@ -76,33 +124,18 @@ export class PcbScene3dBuilder {
76
124
  )
77
125
  .filter(Boolean),
78
126
  detail: {
127
+ embeddedFonts: Array.isArray(pcb.embeddedFonts)
128
+ ? pcb.embeddedFonts
129
+ : [],
79
130
  pads,
80
131
  tracks,
81
132
  arcs,
82
133
  fills,
83
- vias: Array.isArray(pcb.vias) ? pcb.vias : [],
134
+ vias,
84
135
  polygons: Array.isArray(pcb.polygons) ? pcb.polygons : [],
85
136
  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
- )
137
+ top: topSilkscreen,
138
+ bottom: bottomSilkscreen
106
139
  }
107
140
  }
108
141
  }
@@ -163,8 +196,9 @@ export class PcbScene3dBuilder {
163
196
  /**
164
197
  * Builds one explicit external-model placement from normalized component
165
198
  * 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
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
167
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
168
202
  * @param {{ centerX: number, centerY: number }} board
169
203
  * @param {number} thicknessMil
170
204
  * @param {{ resolveComponentBodyModel?: (componentBody: any) => { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null } | null} modelRegistry
@@ -173,6 +207,7 @@ export class PcbScene3dBuilder {
173
207
  static #buildExternalPlacement(
174
208
  componentBody,
175
209
  matchedComponent,
210
+ components,
176
211
  board,
177
212
  thicknessMil,
178
213
  modelRegistry
@@ -190,16 +225,18 @@ export class PcbScene3dBuilder {
190
225
  return null
191
226
  }
192
227
 
193
- const mountSide =
194
- String(matchedComponent?.layer || 'TOP').toUpperCase() === 'BOTTOM'
195
- ? 'bottom'
196
- : 'top'
228
+ const mountSide = PcbScene3dPlacementSideResolver.resolvePlacementSide(
229
+ componentBody,
230
+ matchedComponent,
231
+ components
232
+ )
197
233
  const halfBoardThickness = thicknessMil / 2
198
234
  const sourcePosition =
199
235
  PcbScene3dBuilder.#resolveExternalPlacementSourcePosition(
200
- componentBody,
201
- matchedComponent
236
+ componentBody
202
237
  )
238
+ const modelRotation =
239
+ PcbScene3dBuilder.#resolveExternalModelRotation(componentBody)
203
240
 
204
241
  return {
205
242
  designator:
@@ -226,11 +263,7 @@ export class PcbScene3dBuilder {
226
263
  },
227
264
  bodyRotationDeg: Number(componentBody.rotationDeg || 0),
228
265
  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
- },
266
+ rotationDeg: modelRotation,
234
267
  dzMil: Number(componentBody.dzMil || 0)
235
268
  },
236
269
  externalModel: resolvedModel
@@ -251,6 +284,10 @@ export class PcbScene3dBuilder {
251
284
  const assignedBodyIndexes = new Set()
252
285
  const assignedComponentIndexes = new Set()
253
286
  const closeCandidates = []
287
+ const matchContext = PcbScene3dBuilder.#buildBodyMatchContext(
288
+ componentBodies,
289
+ components
290
+ )
254
291
 
255
292
  componentBodies.forEach((componentBody, bodyIndex) => {
256
293
  components.forEach((component, componentIndex) => {
@@ -260,7 +297,15 @@ export class PcbScene3dBuilder {
260
297
  component
261
298
  )
262
299
 
263
- if (distance <= 600) {
300
+ if (
301
+ distance <= 600 &&
302
+ PcbScene3dBuilder.#canUseCloseBodyComponentMatch(
303
+ componentBody,
304
+ component,
305
+ matchContext,
306
+ distance
307
+ )
308
+ ) {
264
309
  closeCandidates.push({
265
310
  bodyIndex,
266
311
  componentIndex,
@@ -288,7 +333,9 @@ export class PcbScene3dBuilder {
288
333
  const groupedBodyIndexes = new Map()
289
334
  componentBodies.forEach((componentBody, bodyIndex) => {
290
335
  const groupKey =
291
- PcbScene3dBuilder.#resolveBodyGroupKey(componentBody)
336
+ PcbScene3dPlacementSideResolver.resolveBodyGroupKey(
337
+ componentBody
338
+ )
292
339
  if (!groupedBodyIndexes.has(groupKey)) {
293
340
  groupedBodyIndexes.set(groupKey, [])
294
341
  }
@@ -316,7 +363,7 @@ export class PcbScene3dBuilder {
316
363
  bodyIndexes.includes(
317
364
  matches.indexOf(components[componentIndex])
318
365
  )) &&
319
- PcbScene3dBuilder.#scoreBodyComponentAffinity(
366
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
320
367
  referenceBody,
321
368
  component
322
369
  ) > 0
@@ -349,6 +396,87 @@ export class PcbScene3dBuilder {
349
396
  return matches
350
397
  }
351
398
 
399
+ /**
400
+ * Builds reusable identity statistics for body/component matching.
401
+ * @param {{ modelId?: string, name?: string, identifier?: string }[]} componentBodies
402
+ * @param {{ pattern?: string, source?: string, modelPath?: string }[]} components
403
+ * @returns {{ bodyGroupCounts: Map<string, number>, candidateComponentCounts: Map<string, number> }}
404
+ */
405
+ static #buildBodyMatchContext(componentBodies, components) {
406
+ const bodyGroupCounts = new Map()
407
+ const bodyByGroup = new Map()
408
+ const candidateComponentCounts = new Map()
409
+
410
+ for (const componentBody of componentBodies) {
411
+ const groupKey =
412
+ PcbScene3dPlacementSideResolver.resolveBodyGroupKey(
413
+ componentBody
414
+ )
415
+ bodyGroupCounts.set(
416
+ groupKey,
417
+ (bodyGroupCounts.get(groupKey) || 0) + 1
418
+ )
419
+ if (!bodyByGroup.has(groupKey)) {
420
+ bodyByGroup.set(groupKey, componentBody)
421
+ }
422
+ }
423
+
424
+ bodyByGroup.forEach((componentBody, groupKey) => {
425
+ candidateComponentCounts.set(
426
+ groupKey,
427
+ components.filter(
428
+ (component) =>
429
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
430
+ componentBody,
431
+ component
432
+ ) > 0
433
+ ).length
434
+ )
435
+ })
436
+
437
+ return { bodyGroupCounts, candidateComponentCounts }
438
+ }
439
+
440
+ /**
441
+ * Returns true when a close body/component pair is identity-compatible and
442
+ * the body group can be matched one-to-one to component anchors.
443
+ * @param {{ modelId?: string, name?: string, identifier?: string }} componentBody
444
+ * @param {{ pattern?: string, source?: string, modelPath?: string }} component
445
+ * @param {{ bodyGroupCounts: Map<string, number>, candidateComponentCounts: Map<string, number> }} matchContext
446
+ * @param {number} distanceMil Distance between body and component anchors.
447
+ * @returns {boolean}
448
+ */
449
+ static #canUseCloseBodyComponentMatch(
450
+ componentBody,
451
+ component,
452
+ matchContext,
453
+ distanceMil
454
+ ) {
455
+ if (
456
+ Number(distanceMil) <=
457
+ PcbScene3dBuilder.#PRECISE_BODY_MATCH_TOLERANCE_MIL
458
+ ) {
459
+ return true
460
+ }
461
+
462
+ if (
463
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
464
+ componentBody,
465
+ component
466
+ ) <= 0
467
+ ) {
468
+ return false
469
+ }
470
+
471
+ const groupKey =
472
+ PcbScene3dPlacementSideResolver.resolveBodyGroupKey(componentBody)
473
+ const bodyCount = matchContext.bodyGroupCounts.get(groupKey) || 0
474
+ const candidateCount =
475
+ matchContext.candidateComponentCounts.get(groupKey) || 0
476
+
477
+ return bodyCount > 0 && bodyCount <= candidateCount
478
+ }
479
+
352
480
  /**
353
481
  * Pairs one unresolved repeated body group with a repeated component group
354
482
  * by preserving the dominant ordering axis and choosing the pairing that
@@ -506,23 +634,12 @@ export class PcbScene3dBuilder {
506
634
  }
507
635
 
508
636
  /**
509
- * Returns the component anchor that should be used for one resolved body
637
+ * Returns the native body anchor that should be used for one explicit model
510
638
  * placement.
511
639
  * @param {{ positionMil?: { x?: number, y?: number } }} componentBody
512
- * @param {{ x: number, y: number } | null} matchedComponent
513
640
  * @returns {{ x: number, y: number }}
514
641
  */
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
-
642
+ static #resolveExternalPlacementSourcePosition(componentBody) {
526
643
  return {
527
644
  x: Number(componentBody?.positionMil?.x || 0),
528
645
  y: Number(componentBody?.positionMil?.y || 0)
@@ -530,20 +647,422 @@ export class PcbScene3dBuilder {
530
647
  }
531
648
 
532
649
  /**
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.
650
+ * Resolves the authored placement rotation for one explicit external model.
651
+ * Altium stores the 3D model's board-facing yaw in MODEL.3D.ROTZ.
536
652
  * @param {{ rotationDeg?: number }} componentBody
537
653
  * @param {{ rotation?: number } | null} matchedComponent
538
654
  * @returns {number}
539
655
  */
540
656
  static #resolveExternalPlacementRotation(componentBody, matchedComponent) {
657
+ const modelRotationZ = Number(componentBody?.modelRotationDeg?.z)
658
+ if (Number.isFinite(modelRotationZ)) {
659
+ return PcbScene3dBuilder.#normalizeAngle(modelRotationZ)
660
+ }
661
+
662
+ return PcbScene3dBuilder.#normalizeAngle(
663
+ Number(componentBody?.rotationDeg || 0) +
664
+ Number(matchedComponent?.rotation || 0)
665
+ )
666
+ }
667
+
668
+ /**
669
+ * Resolves model-local rotations after converting Altium's positive local
670
+ * rotation fields into the renderer's signed 3D model convention.
671
+ * @param {{ modelRotationDeg?: { x?: number, y?: number, z?: number } }} componentBody
672
+ * @returns {{ x: number, y: number, z: number }}
673
+ */
674
+ static #resolveExternalModelRotation(componentBody) {
675
+ return {
676
+ x: -Number(componentBody?.modelRotationDeg?.x || 0),
677
+ y: -Number(componentBody?.modelRotationDeg?.y || 0),
678
+ z: 0
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Selects and normalizes one board-side silkscreen primitive set.
684
+ * @param {{ layerId: number, name: string }[]} primitiveLayers
685
+ * @param {{ x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[]} fills
686
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[]} tracks
687
+ * @param {{ x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[]} arcs
688
+ * @param {{ text?: string, value?: string, x?: number, y?: number, height?: number, strokeWidth?: number, layerCode?: number, layerId?: number, visible?: boolean }[]} texts
689
+ * @param {{ points?: object[], holes?: object[][], layerCode?: number, layerId?: number }[]} regions
690
+ * @param {{ minX?: number, minY?: number, widthMil?: number, heightMil?: number }} boardOutline
691
+ * @param {'top' | 'bottom'} side
692
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
693
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
694
+ * @returns {{ fills: object[], tracks: object[], arcs: object[], regions: object[], texts: object[], nativeTextKnockouts: boolean }}
695
+ */
696
+ static #buildSilkscreenSide(
697
+ primitiveLayers,
698
+ fills,
699
+ tracks,
700
+ arcs,
701
+ texts,
702
+ regions,
703
+ boardOutline,
704
+ side,
705
+ pads,
706
+ vias
707
+ ) {
708
+ const normalized = PcbEdgeFacingGlyphNormalizer.normalize(
709
+ PcbFootprintPrimitiveSelector.select(
710
+ primitiveLayers,
711
+ fills,
712
+ tracks,
713
+ arcs,
714
+ regions,
715
+ side
716
+ ),
717
+ boardOutline
718
+ )
719
+ const fillsWithRegions = [
720
+ ...(normalized.fills || []),
721
+ ...(normalized.regions || [])
722
+ ]
723
+ const denseOverlayArtwork = PcbScene3dBuilder.#isDenseOverlayArtwork(
724
+ {
725
+ fills: fillsWithRegions,
726
+ tracks: normalized.tracks,
727
+ arcs: normalized.arcs
728
+ },
729
+ boardOutline
730
+ )
731
+
732
+ return {
733
+ ...normalized,
734
+ denseOverlayArtwork,
735
+ nativeTextKnockouts: PcbScene3dBuilder.#hasNativeTextKnockouts(
736
+ fillsWithRegions,
737
+ normalized,
738
+ boardOutline
739
+ ),
740
+ texts: PcbScene3dBuilder.#selectSilkscreenTexts(
741
+ primitiveLayers,
742
+ texts,
743
+ side
744
+ ),
745
+ tracks: normalized.tracks,
746
+ fills: PcbScene3dDrillCutoutBuilder.clipFills(
747
+ fillsWithRegions,
748
+ pads,
749
+ vias
750
+ )
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Applies optional appearance hints for overlay artwork that carries broad
756
+ * silkscreen graphics plus dense board-colored linework.
757
+ * @param {{ fills?: any[], tracks?: any[], arcs?: any[], fillColor?: number, strokeColor?: number }} topSilkscreen
758
+ * @param {{ fills?: any[], tracks?: any[], arcs?: any[], fillColor?: number, strokeColor?: number }} bottomSilkscreen
759
+ * @param {{ widthMil?: number, heightMil?: number }} board
760
+ * @param {{ silkscreenTopColor?: number, silkscreenBottomColor?: number }} appearance3d
761
+ * @returns {void}
762
+ */
763
+ static #applySilkscreenAppearance(
764
+ topSilkscreen,
765
+ bottomSilkscreen,
766
+ board,
767
+ appearance3d
768
+ ) {
769
+ PcbScene3dBuilder.#styleSilkscreenArtwork(
770
+ topSilkscreen,
771
+ board,
772
+ appearance3d.silkscreenTopColor
773
+ )
774
+ PcbScene3dBuilder.#styleSilkscreenArtwork(
775
+ bottomSilkscreen,
776
+ board,
777
+ appearance3d.silkscreenBottomColor
778
+ )
779
+ }
780
+
781
+ /**
782
+ * Applies silkscreen colors and marks dense overlay art as light filled
783
+ * areas with app-board-colored strokes.
784
+ * @param {{ fills?: any[], tracks?: any[], arcs?: any[], fillColor?: number, strokeColor?: number, knockoutColor?: number, denseOverlayArtwork?: boolean }} side
785
+ * @param {{ widthMil?: number, heightMil?: number }} board
786
+ * @param {number | undefined} silkscreenColor
787
+ * @returns {void}
788
+ */
789
+ static #styleSilkscreenArtwork(side, board, silkscreenColor) {
790
+ if (Number.isInteger(silkscreenColor)) {
791
+ side.strokeColor = silkscreenColor
792
+ }
793
+
794
+ if (
795
+ !side?.denseOverlayArtwork &&
796
+ !PcbScene3dBuilder.#isDenseOverlayArtwork(side, board)
797
+ ) {
798
+ return
799
+ }
800
+
801
+ side.fillColor = Number.isInteger(silkscreenColor)
802
+ ? silkscreenColor
803
+ : PcbScene3dBuilder.#DENSE_OVERLAY_FILL_COLOR
804
+ side.strokeColor = side.fillColor
805
+ side.knockoutColor = PcbScene3dBuilder.#DENSE_OVERLAY_KNOCKOUT_COLOR
806
+ }
807
+
808
+ /**
809
+ * Selects visible side-specific silkscreen texts.
810
+ * @param {{ layerId: number, name: string }[]} primitiveLayers
811
+ * @param {{ text?: string, value?: string, x?: number, y?: number, height?: number, strokeWidth?: number, layerCode?: number, layerId?: number, visible?: boolean }[]} texts
812
+ * @param {'top' | 'bottom'} side
813
+ * @returns {object[]}
814
+ */
815
+ static #selectSilkscreenTexts(primitiveLayers, texts, side) {
816
+ const layerIds = PcbScene3dBuilder.#resolveSilkscreenLayerIds(
817
+ primitiveLayers,
818
+ side
819
+ )
820
+
821
+ return (Array.isArray(texts) ? texts : [])
822
+ .filter((text) => text?.visible !== false)
823
+ .filter((text) => layerIds.has(Number(text?.layerId)))
824
+ .map((text) =>
825
+ PcbScene3dBuilder.#normalizeSilkscreenText(text, side)
826
+ )
827
+ }
828
+
829
+ /**
830
+ * Resolves layer IDs that belong to one overlay side.
831
+ * @param {{ layerId: number, name: string }[]} primitiveLayers
832
+ * @param {'top' | 'bottom'} side
833
+ * @returns {Set<number>}
834
+ */
835
+ static #resolveSilkscreenLayerIds(primitiveLayers, side) {
836
+ const needle = side === 'bottom' ? 'BOTTOM OVERLAY' : 'TOP OVERLAY'
837
+
838
+ return new Set(
839
+ (Array.isArray(primitiveLayers) ? primitiveLayers : [])
840
+ .filter((layer) =>
841
+ String(layer?.name || '')
842
+ .trim()
843
+ .toUpperCase()
844
+ .includes(needle)
845
+ )
846
+ .map((layer) => Number(layer.layerId))
847
+ .filter((layerId) => Number.isInteger(layerId))
848
+ )
849
+ }
850
+
851
+ /**
852
+ * Normalizes one Altium overlay text into the runtime stroke-text shape.
853
+ * @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
854
+ * @param {'top' | 'bottom'} side
855
+ * @returns {object}
856
+ */
857
+ static #normalizeSilkscreenText(text, side) {
858
+ const height = Math.max(Number(text?.height || 0), 1)
859
+ const textBox = PcbScene3dTextBoxLayoutResolver.resolve(text)
860
+
861
+ return {
862
+ ...text,
863
+ ...(textBox ? { textBox } : {}),
864
+ text: String(text?.text ?? text?.value ?? ''),
865
+ value: String(text?.text ?? text?.value ?? ''),
866
+ sizeX: height,
867
+ sizeY:
868
+ height *
869
+ PcbScene3dBuilder.#resolveSilkscreenTextWidthRatio(text),
870
+ thickness: Math.max(Number(text?.strokeWidth || 0), 1),
871
+ hAlign: 'left',
872
+ vAlign: 'bottom',
873
+ mirrored: PcbScene3dBuilder.#resolveSilkscreenTextMirrored(text),
874
+ side,
875
+ rotation: PcbScene3dBuilder.#resolveSilkscreenTextRotation(text)
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Converts screen-space Altium text rotation for the shared 3D text factories.
881
+ * @param {{ rotation?: number | string }} text
882
+ * @returns {number}
883
+ */
884
+ static #resolveSilkscreenTextRotation(text) {
541
885
  return PcbScene3dBuilder.#normalizeAngle(
542
- Number(matchedComponent?.rotation || 0) +
543
- Number(componentBody?.rotationDeg || 0)
886
+ 360 - Number(text?.rotation || 0)
544
887
  )
545
888
  }
546
889
 
890
+ /**
891
+ * Checks whether one silkscreen text primitive uses TrueType glyphs.
892
+ * @param {{ fontTypeName?: string, fontType?: number | string, isTrueType?: boolean }} text
893
+ * @returns {boolean}
894
+ */
895
+ static #isTrueTypeSilkscreenText(text) {
896
+ const fontTypeName = String(text?.fontTypeName || '').toUpperCase()
897
+
898
+ return (
899
+ text?.isTrueType === true ||
900
+ Number(text?.fontType) === 1 ||
901
+ fontTypeName.includes('TRUETYPE')
902
+ )
903
+ }
904
+
905
+ /**
906
+ * Resolves the horizontal glyph scale used by the 3D stroke approximation.
907
+ * @param {{ fontTypeName?: string, fontType?: number | string, isTrueType?: boolean }} text
908
+ * @returns {number}
909
+ */
910
+ static #resolveSilkscreenTextWidthRatio(text) {
911
+ return PcbScene3dBuilder.#isTrueTypeSilkscreenText(text)
912
+ ? PcbScene3dBuilder.#TRUETYPE_TEXT_WIDTH_RATIO
913
+ : 1
914
+ }
915
+
916
+ /**
917
+ * Resolves Altium's explicit per-text mirror flag.
918
+ * @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
919
+ * @returns {boolean}
920
+ */
921
+ static #resolveSilkscreenTextMirrored(text) {
922
+ const value =
923
+ text?.mirrored ??
924
+ text?.isMirrored ??
925
+ text?.mirrorFlag ??
926
+ text?.Mirrored ??
927
+ text?.IsMirrored ??
928
+ text?.MirrorFlag
929
+
930
+ if (typeof value === 'boolean') return value
931
+ if (typeof value === 'number') return value !== 0
932
+
933
+ return /^(1|true|yes|y)$/iu.test(String(value ?? '').trim())
934
+ }
935
+
936
+ /**
937
+ * Detects dense Altium overlay regions that already carry text knockouts
938
+ * as native fill holes.
939
+ * @param {any[]} fills
940
+ * @param {{ tracks?: any[], arcs?: any[] }} primitives
941
+ * @param {{ widthMil?: number, heightMil?: number }} board
942
+ * @returns {boolean}
943
+ */
944
+ static #hasNativeTextKnockouts(fills, primitives, board) {
945
+ return (
946
+ (Boolean(primitives?.denseOverlayArtwork) ||
947
+ PcbScene3dBuilder.#isDenseOverlayArtwork(
948
+ {
949
+ fills,
950
+ tracks: primitives?.tracks,
951
+ arcs: primitives?.arcs
952
+ },
953
+ board
954
+ )) &&
955
+ (Array.isArray(fills) ? fills : []).some(
956
+ (fill) => Array.isArray(fill?.holes) && fill.holes.length > 0
957
+ )
958
+ )
959
+ }
960
+
961
+ /**
962
+ * Detects overlay art from structural density rather than file-specific
963
+ * labels or source identifiers.
964
+ * @param {{ fills?: any[], tracks?: any[], arcs?: any[] }} side
965
+ * @param {{ widthMil?: number, heightMil?: number }} board
966
+ * @returns {boolean}
967
+ */
968
+ static #isDenseOverlayArtwork(side, board) {
969
+ const strokeCount =
970
+ (Array.isArray(side?.tracks) ? side.tracks.length : 0) +
971
+ (Array.isArray(side?.arcs) ? side.arcs.length : 0)
972
+
973
+ return (
974
+ strokeCount >= PcbScene3dBuilder.#DENSE_OVERLAY_MIN_TRACK_COUNT &&
975
+ PcbScene3dBuilder.#maxFillAreaRatio(side?.fills, board) >=
976
+ PcbScene3dBuilder.#DENSE_OVERLAY_MIN_REGION_AREA_RATIO
977
+ )
978
+ }
979
+
980
+ /**
981
+ * Resolves the largest fill-to-board bounding-box area ratio.
982
+ * @param {any[] | undefined} fills
983
+ * @param {{ widthMil?: number, heightMil?: number }} board
984
+ * @returns {number}
985
+ */
986
+ static #maxFillAreaRatio(fills, board) {
987
+ const boardArea =
988
+ Math.max(Number(board?.widthMil || 0), 0) *
989
+ Math.max(Number(board?.heightMil || 0), 0)
990
+ if (!boardArea) {
991
+ return 0
992
+ }
993
+
994
+ return (Array.isArray(fills) ? fills : []).reduce((maxRatio, fill) => {
995
+ const bounds = PcbScene3dBuilder.#resolveFillBounds(fill)
996
+ if (!bounds) {
997
+ return maxRatio
998
+ }
999
+
1000
+ const fillArea =
1001
+ Math.max(bounds.maxX - bounds.minX, 0) *
1002
+ Math.max(bounds.maxY - bounds.minY, 0)
1003
+
1004
+ return Math.max(maxRatio, fillArea / boardArea)
1005
+ }, 0)
1006
+ }
1007
+
1008
+ /**
1009
+ * Resolves rough authored bounds for one rectangular or polygon fill.
1010
+ * @param {{ x1?: number, y1?: number, x2?: number, y2?: number, points?: { x?: number, y?: number }[] }} fill
1011
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
1012
+ */
1013
+ static #resolveFillBounds(fill) {
1014
+ const points = Array.isArray(fill?.points)
1015
+ ? fill.points
1016
+ .map((point) => ({
1017
+ x: Number(point?.x),
1018
+ y: Number(point?.y)
1019
+ }))
1020
+ .filter(
1021
+ (point) =>
1022
+ Number.isFinite(point.x) && Number.isFinite(point.y)
1023
+ )
1024
+ : [
1025
+ { x: Number(fill?.x1), y: Number(fill?.y1) },
1026
+ { x: Number(fill?.x2), y: Number(fill?.y2) }
1027
+ ].filter(
1028
+ (point) =>
1029
+ Number.isFinite(point.x) && Number.isFinite(point.y)
1030
+ )
1031
+
1032
+ if (points.length < 2) {
1033
+ return null
1034
+ }
1035
+
1036
+ const xs = points.map((point) => point.x)
1037
+ const ys = points.map((point) => point.y)
1038
+
1039
+ return {
1040
+ minX: Math.min(...xs),
1041
+ minY: Math.min(...ys),
1042
+ maxX: Math.max(...xs),
1043
+ maxY: Math.max(...ys)
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Resolves region primitives that can contribute filled silkscreen artwork.
1049
+ * @param {{ regions?: object[], shapeBasedRegions?: object[] }} pcb
1050
+ * @returns {object[]}
1051
+ */
1052
+ static #resolveSilkscreenRegions(pcb) {
1053
+ if (
1054
+ Array.isArray(pcb?.shapeBasedRegions) &&
1055
+ pcb.shapeBasedRegions.length
1056
+ ) {
1057
+ return pcb.shapeBasedRegions.map((region) => ({
1058
+ ...region,
1059
+ isShapeBased: true
1060
+ }))
1061
+ }
1062
+
1063
+ return Array.isArray(pcb?.regions) ? pcb.regions : []
1064
+ }
1065
+
547
1066
  /**
548
1067
  * Resolves a rough pad-span box around one component.
549
1068
  * @param {{ x: number, y: number }} component
@@ -619,85 +1138,6 @@ export class PcbScene3dBuilder {
619
1138
  )
620
1139
  }
621
1140
 
622
- /**
623
- * Resolves the grouping key for repeated component-body matching.
624
- * @param {{ modelId?: string, name?: string, identifier?: string }} componentBody
625
- * @returns {string}
626
- */
627
- static #resolveBodyGroupKey(componentBody) {
628
- return PcbScene3dBuilder.#normalizeLookupToken(
629
- componentBody?.modelId ||
630
- componentBody?.name ||
631
- componentBody?.identifier
632
- )
633
- }
634
-
635
- /**
636
- * Scores how strongly one component record appears to belong to one body
637
- * record based on shared model/footprint tokens.
638
- * @param {{ name?: string, identifier?: string }} componentBody
639
- * @param {{ pattern?: string, source?: string, modelPath?: string }} component
640
- * @returns {number}
641
- */
642
- static #scoreBodyComponentAffinity(componentBody, component) {
643
- const bodyTokens = PcbScene3dBuilder.#collectMeaningfulTokens([
644
- componentBody?.identifier,
645
- String(componentBody?.name || '').replace(/\.[^.]+$/, '')
646
- ])
647
- const componentTokens = PcbScene3dBuilder.#collectMeaningfulTokens([
648
- component?.pattern,
649
- component?.source,
650
- component?.modelPath
651
- ])
652
- let score = 0
653
-
654
- bodyTokens.forEach((token) => {
655
- if (componentTokens.has(token)) {
656
- score += token.length
657
- }
658
- })
659
-
660
- return score
661
- }
662
-
663
- /**
664
- * Collects normalized model tokens from free-form strings.
665
- * @param {(string | undefined)[]} values
666
- * @returns {Set<string>}
667
- */
668
- static #collectMeaningfulTokens(values) {
669
- const tokens = new Set()
670
-
671
- ;(Array.isArray(values) ? values : []).forEach((value) => {
672
- String(value || '')
673
- .toLowerCase()
674
- .split(/[^a-z0-9]+/g)
675
- .forEach((fragment) => {
676
- ;(fragment.match(/[a-z]+|\d+/g) || []).forEach((token) => {
677
- if (PcbScene3dBuilder.#isMeaningfulToken(token)) {
678
- tokens.add(token)
679
- }
680
- })
681
- })
682
- })
683
-
684
- return tokens
685
- }
686
-
687
- /**
688
- * Returns true when one normalized token carries useful model identity.
689
- * @param {string} token
690
- * @returns {boolean}
691
- */
692
- static #isMeaningfulToken(token) {
693
- return (
694
- String(token || '').length >= 2 &&
695
- !new Set(['con', 'step', 'stp', 'model', 'default', 'black']).has(
696
- String(token || '')
697
- )
698
- )
699
- }
700
-
701
1141
  /**
702
1142
  * Returns true when one unresolved body anchor still lies close enough to
703
1143
  * the board envelope to be renderable without a component match.
@@ -718,17 +1158,6 @@ export class PcbScene3dBuilder {
718
1158
  return bodyX >= minX && bodyX <= maxX && bodyY >= minY && bodyY <= maxY
719
1159
  }
720
1160
 
721
- /**
722
- * Normalizes one lookup token for repeated-model grouping.
723
- * @param {string | undefined} value
724
- * @returns {string}
725
- */
726
- static #normalizeLookupToken(value) {
727
- return String(value || '')
728
- .toLowerCase()
729
- .replace(/[^a-z0-9]+/g, '')
730
- }
731
-
732
1161
  /**
733
1162
  * Normalizes one angle into the range [0, 360).
734
1163
  * @param {number} angle