altium-toolkit 1.4.15 → 1.4.17

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.
@@ -7,8 +7,8 @@ SPDX-License-Identifier: CC-BY-SA-4.0
7
7
  # Library Scope
8
8
 
9
9
  Altium Toolkit provides reusable native Altium parsing behind the common ECAD
10
- toolkit API. Canonical results use CircuitJSON; source-only facts and all
11
- historical 1.1.41 contracts remain explicit Altium extensions.
10
+ toolkit API. Canonical results use CircuitJSON; source-only facts and historical
11
+ 1.1.41 APIs remain explicit Altium extensions with maintained implementations.
12
12
 
13
13
  ## In Scope
14
14
 
@@ -18,7 +18,8 @@ historical 1.1.41 contracts remain explicit Altium extensions.
18
18
  reusable document contexts
19
19
  - Shared CircuitJSON render, interaction, query, manufacturing, simulation,
20
20
  and data-only 3D scene services
21
- - Exact `/extensions` preservation of audited Altium 1.1.41 behavior
21
+ - Preservation of audited Altium 1.1.41 `/extensions` API signatures and public
22
+ asset entrypoints and targets, with tested bug fixes and performance improvements
22
23
  - `.SchDoc`, `.PcbDoc`, `.PCBDwf`, `.SchLib`, `.PcbLib`, `.PrjPcb`, `.PrjScr`,
23
24
  and `.IntLib` parsing from `ArrayBuffer`
24
25
  - OLE and binary stream helpers needed by parser recovery
@@ -74,18 +74,9 @@ export class AltiumWorkerClient {
74
74
  */
75
75
  static #client() {
76
76
  if (!client) {
77
- client = new ParserWorkerClient({
78
- createWorker: () => {
79
- const WorkerConstructor = globalThis.Worker
80
- return Reflect.construct(WorkerConstructor, [
81
- new URL(
82
- '../workers/parser.worker.mjs',
83
- import.meta.url
84
- ),
85
- { type: 'module' }
86
- ])
87
- }
88
- })
77
+ client = ParserWorkerClient.fromWorkerUrl(
78
+ new URL('../workers/parser.worker.mjs', import.meta.url)
79
+ )
89
80
  }
90
81
  return client
91
82
  }
@@ -0,0 +1,95 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ /** Matches placements to authored bodies within one adapter invocation. */
5
+ export class AltiumScene3dBodyPlacementIndex {
6
+ #cells = new Map()
7
+
8
+ /**
9
+ * Groups authored body positions into one-mil cells in source order.
10
+ * @param {object[]} componentBodies Source component bodies.
11
+ */
12
+ constructor(componentBodies) {
13
+ componentBodies.forEach((componentBody, order) => {
14
+ const x = Number(componentBody?.positionMil?.x || 0)
15
+ const y = Number(componentBody?.positionMil?.y || 0)
16
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return
17
+ const key = `${Math.floor(x)}:${Math.floor(y)}`
18
+ const cell = this.#cells.get(key) || []
19
+ cell.push({ componentBody, x, y, order })
20
+ this.#cells.set(key, cell)
21
+ })
22
+ }
23
+
24
+ /**
25
+ * Selects by identity affinity, distance, then original source order.
26
+ * @param {object} placement Built external placement.
27
+ * @returns {object | null}
28
+ */
29
+ resolve(placement) {
30
+ const x = Number(placement?.bodyPositionMil?.x || 0)
31
+ const y = Number(placement?.bodyPositionMil?.y || 0)
32
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null
33
+ const placementText = AltiumScene3dBodyPlacementIndex.#identityText([
34
+ placement?.designator,
35
+ placement?.externalModel?.name
36
+ ])
37
+ let best = null
38
+ let bestScore = -1
39
+ let bestDistance = Infinity
40
+ for (const dx of [-1, 0, 1]) {
41
+ for (const dy of [-1, 0, 1]) {
42
+ const cell =
43
+ this.#cells.get(
44
+ `${Math.floor(x) + dx}:${Math.floor(y) + dy}`
45
+ ) || []
46
+ for (const candidate of cell) {
47
+ const distance = Math.hypot(
48
+ x - candidate.x,
49
+ y - candidate.y
50
+ )
51
+ if (!(distance <= 0.01)) continue
52
+ const bodyText =
53
+ AltiumScene3dBodyPlacementIndex.#identityText([
54
+ candidate.componentBody?.identifier,
55
+ candidate.componentBody?.name
56
+ ])
57
+ const score =
58
+ placementText &&
59
+ bodyText &&
60
+ placementText.includes(bodyText)
61
+ ? bodyText.length
62
+ : 0
63
+ if (
64
+ best &&
65
+ !(
66
+ score > bestScore ||
67
+ (score === bestScore &&
68
+ (distance < bestDistance ||
69
+ (distance === bestDistance &&
70
+ candidate.order < best.order)))
71
+ )
72
+ )
73
+ continue
74
+ best = candidate
75
+ bestScore = score
76
+ bestDistance = distance
77
+ }
78
+ }
79
+ }
80
+ return best?.componentBody || null
81
+ }
82
+
83
+ /**
84
+ * Normalizes metadata with the placement adapter's identity convention.
85
+ * @param {unknown[]} values Source identity fields.
86
+ * @returns {string}
87
+ */
88
+ static #identityText(values) {
89
+ return values
90
+ .map((value) => String(value || '').toLowerCase())
91
+ .join(' ')
92
+ .replace(/\.[a-z0-9]+\\b/g, '')
93
+ .replace(/[^a-z0-9]+/g, '')
94
+ }
95
+ }
@@ -1,3 +1,4 @@
1
+ import { PcbScene3dComponentGeometryResolver } from './PcbScene3dComponentGeometryResolver.mjs'
1
2
  import { PcbScene3dPadLocalSpanResolver } from './PcbScene3dPadLocalSpanResolver.mjs'
2
3
 
3
4
  const REFINABLE_FAMILIES = new Set(['chip', 'diode', 'generic', 'ic', 'sot'])
@@ -33,6 +34,10 @@ export class AltiumScene3dComponentBodyAdapter {
33
34
  return sceneDescription
34
35
  }
35
36
 
37
+ const componentGeometry = new PcbScene3dComponentGeometryResolver(
38
+ sourceComponents,
39
+ pads
40
+ )
36
41
  const sourceByDesignator = new Map(
37
42
  sourceComponents.map((component) => [
38
43
  String(component?.designator || ''),
@@ -46,7 +51,7 @@ export class AltiumScene3dComponentBodyAdapter {
46
51
  AltiumScene3dComponentBodyAdapter.#refineComponent(
47
52
  component,
48
53
  sourceByDesignator.get(String(component?.designator || '')),
49
- pads
54
+ componentGeometry
50
55
  )
51
56
  )
52
57
  }
@@ -56,10 +61,10 @@ export class AltiumScene3dComponentBodyAdapter {
56
61
  * Refines one procedural component body when nearby pads overinflated it.
57
62
  * @param {object} component Scene component.
58
63
  * @param {object | undefined} sourceComponent Source PCB component.
59
- * @param {object[]} pads Source PCB pads.
64
+ * @param {PcbScene3dComponentGeometryResolver} componentGeometry Build-scoped geometry.
60
65
  * @returns {object}
61
66
  */
62
- static #refineComponent(component, sourceComponent, pads) {
67
+ static #refineComponent(component, sourceComponent, componentGeometry) {
63
68
  const family = String(component?.body?.family || '')
64
69
  if (
65
70
  component?.externalModel ||
@@ -72,7 +77,7 @@ export class AltiumScene3dComponentBodyAdapter {
72
77
  const span = AltiumScene3dComponentBodyAdapter.#ownedPadSpan(
73
78
  sourceComponent,
74
79
  component.mountSide,
75
- pads
80
+ componentGeometry
76
81
  )
77
82
  const size = component?.body?.sizeMil || {}
78
83
  if (
@@ -99,42 +104,17 @@ export class AltiumScene3dComponentBodyAdapter {
99
104
  * Resolves the owned surface-pad span for one source component.
100
105
  * @param {object} component Source component.
101
106
  * @param {string} mountSide Component mount side.
102
- * @param {object[]} pads Source PCB pads.
107
+ * @param {PcbScene3dComponentGeometryResolver} componentGeometry Build-scoped geometry.
103
108
  * @returns {{ width: number, depth: number } | null}
104
109
  */
105
- static #ownedPadSpan(component, mountSide, pads) {
106
- const componentIndex = Number(component?.componentIndex)
107
- if (!Number.isFinite(componentIndex)) {
108
- return null
109
- }
110
-
111
- const ownedPads = pads.filter(
112
- (pad) => Number(pad?.componentIndex) === componentIndex
113
- )
114
- const surfacePads = ownedPads.filter((pad) =>
115
- AltiumScene3dComponentBodyAdapter.#isSurfacePad(pad, mountSide)
116
- )
117
- const spanPads = surfacePads.length ? surfacePads : ownedPads
118
-
110
+ static #ownedPadSpan(component, mountSide, componentGeometry) {
119
111
  return PcbScene3dPadLocalSpanResolver.resolve(
120
112
  component,
121
- spanPads,
113
+ componentGeometry.componentPads(component, mountSide),
122
114
  mountSide
123
115
  )
124
116
  }
125
117
 
126
- /**
127
- * Checks whether one pad belongs to the component's mounted surface.
128
- * @param {object} pad Source pad.
129
- * @param {string} mountSide Component mount side.
130
- * @returns {boolean}
131
- */
132
- static #isSurfacePad(pad, mountSide) {
133
- return String(mountSide || '').toLowerCase() === 'bottom'
134
- ? Boolean(pad?.hasBottomPasteMaskOpening)
135
- : Boolean(pad?.hasTopPasteMaskOpening)
136
- }
137
-
138
118
  /**
139
119
  * Checks whether the current body is clearly larger than owned pads.
140
120
  * @param {object} size Current body size.
@@ -1,3 +1,4 @@
1
+ import { AltiumScene3dBodyPlacementIndex } from './AltiumScene3dBodyPlacementIndex.mjs'
1
2
  import { AltiumScene3dIdentityTokens } from './AltiumScene3dIdentityTokens.mjs'
2
3
  import { AltiumScene3dAuthoredConnectorYawPolicy } from './AltiumScene3dAuthoredConnectorYawPolicy.mjs'
3
4
  import { AltiumScene3dPlacementRotationPolicy } from './AltiumScene3dPlacementRotationPolicy.mjs'
@@ -59,6 +60,7 @@ export class AltiumScene3dExternalPlacementAdapter {
59
60
  ])
60
61
  )
61
62
 
63
+ const bodyIndex = new AltiumScene3dBodyPlacementIndex(componentBodies)
62
64
  const repairedScene = {
63
65
  ...sceneDescription,
64
66
  externalPlacements: sceneDescription.externalPlacements
@@ -67,7 +69,7 @@ export class AltiumScene3dExternalPlacementAdapter {
67
69
  placement,
68
70
  components,
69
71
  componentByDesignator,
70
- componentBodies,
72
+ bodyIndex,
71
73
  pads,
72
74
  sceneDescription?.board
73
75
  )
@@ -86,7 +88,7 @@ export class AltiumScene3dExternalPlacementAdapter {
86
88
  placement,
87
89
  components,
88
90
  componentByDesignator,
89
- componentBodies,
91
+ bodyIndex,
90
92
  pads,
91
93
  board
92
94
  ) {
@@ -101,11 +103,7 @@ export class AltiumScene3dExternalPlacementAdapter {
101
103
  return placement
102
104
  }
103
105
 
104
- const componentBody =
105
- AltiumScene3dExternalPlacementAdapter.#resolveComponentBody(
106
- placement,
107
- componentBodies
108
- )
106
+ const componentBody = bodyIndex.resolve(placement)
109
107
  const currentComponent = componentByDesignator.get(
110
108
  String(placement?.designator || '')
111
109
  )
@@ -1542,60 +1540,6 @@ export class AltiumScene3dExternalPlacementAdapter {
1542
1540
  ]
1543
1541
  }
1544
1542
 
1545
- /**
1546
- * Resolves the source component body row for one placement.
1547
- * @param {object} placement External model placement.
1548
- * @param {object[]} componentBodies Source component body rows.
1549
- * @returns {object | null}
1550
- */
1551
- static #resolveComponentBody(placement, componentBodies) {
1552
- const candidates = componentBodies
1553
- .map((componentBody) => ({
1554
- componentBody,
1555
- distance:
1556
- AltiumScene3dExternalPlacementAdapter.#distanceBetweenPoints(
1557
- placement?.bodyPositionMil,
1558
- componentBody?.positionMil
1559
- ),
1560
- identityScore:
1561
- AltiumScene3dExternalPlacementAdapter.#bodyPlacementIdentityScore(
1562
- placement,
1563
- componentBody
1564
- )
1565
- }))
1566
- .filter((candidate) => candidate.distance <= 0.01)
1567
- .sort(
1568
- (left, right) =>
1569
- right.identityScore - left.identityScore ||
1570
- left.distance - right.distance
1571
- )
1572
-
1573
- return candidates[0]?.componentBody || null
1574
- }
1575
-
1576
- /**
1577
- * Scores whether a source body row belongs to one placement.
1578
- * @param {object} placement External model placement.
1579
- * @param {object} componentBody Source component body.
1580
- * @returns {number}
1581
- */
1582
- static #bodyPlacementIdentityScore(placement, componentBody) {
1583
- const placementText =
1584
- AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
1585
- placement?.designator,
1586
- placement?.externalModel?.name
1587
- ])
1588
- const bodyText =
1589
- AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
1590
- componentBody?.identifier,
1591
- componentBody?.name
1592
- ])
1593
-
1594
- return placementText && bodyText && placementText.includes(bodyText)
1595
- ? bodyText.length
1596
- : 0
1597
- }
1598
-
1599
1543
  /**
1600
1544
  * Repairs model-local Altium rotation signs for embedded body transforms.
1601
1545
  * @param {object | null | undefined} modelTransform Placement transform.
@@ -13,6 +13,7 @@ export class AltiumScene3dGeometricOwnerRecovery {
13
13
  static #COMPONENT_INDEX_CELL_MIL = 500
14
14
  static #MINIMUM_SCORE = 12
15
15
  static #MINIMUM_SCORE_MARGIN = 4
16
+ static #PAD_CENTROID_TOLERANCE_MIL = 12
16
17
 
17
18
  /**
18
19
  * Applies geometry-backed owner recovery to final external placements.
@@ -108,14 +109,7 @@ export class AltiumScene3dGeometricOwnerRecovery {
108
109
  const currentOwner = context.componentsByDesignator.get(
109
110
  String(placement?.designator || '')
110
111
  )
111
- if (currentOwner) {
112
- return AltiumScene3dGeometricOwnerRecovery.#correctTactileYaw(
113
- placement,
114
- currentOwner,
115
- body,
116
- context.geometryByComponent.get(currentOwner)
117
- )
118
- }
112
+ if (currentOwner) return placement
119
113
 
120
114
  const match =
121
115
  AltiumScene3dGeometricOwnerRecovery.#resolveGeometricOwner(
@@ -125,18 +119,12 @@ export class AltiumScene3dGeometricOwnerRecovery {
125
119
  )
126
120
  if (!match) return placement
127
121
 
128
- const recovered = AltiumScene3dGeometricOwnerRecovery.#withOwner(
122
+ return AltiumScene3dGeometricOwnerRecovery.#withOwner(
129
123
  placement,
130
124
  body,
131
125
  match,
132
126
  context.board
133
127
  )
134
- return AltiumScene3dGeometricOwnerRecovery.#correctTactileYaw(
135
- recovered,
136
- match.component,
137
- body,
138
- context.geometryByComponent.get(match.component)
139
- )
140
128
  }
141
129
 
142
130
  /**
@@ -329,7 +317,7 @@ export class AltiumScene3dGeometricOwnerRecovery {
329
317
 
330
318
  if (
331
319
  centroidDistance <=
332
- AltiumScene3dGeometricOwnerRecovery.#ANCHOR_TOLERANCE_MIL
320
+ AltiumScene3dGeometricOwnerRecovery.#PAD_CENTROID_TOLERANCE_MIL
333
321
  ) {
334
322
  score = 24
335
323
  mode = 'pad-centroid'
@@ -570,7 +558,9 @@ export class AltiumScene3dGeometricOwnerRecovery {
570
558
  const component = match.component
571
559
  const mountSide =
572
560
  AltiumScene3dGeometricOwnerRecovery.#componentSide(component)
573
- const preserveAnchor = match.mode === 'pad-centroid'
561
+ const preserveAnchor =
562
+ match.mode === 'pad-centroid' ||
563
+ match.mode === 'height-backed-origin'
574
564
  const offset = {
575
565
  x:
576
566
  Number(placement?.bodyPositionMil?.x || 0) -
@@ -590,6 +580,10 @@ export class AltiumScene3dGeometricOwnerRecovery {
590
580
  dzMil: verticalOffset
591
581
  }
592
582
 
583
+ if (match.mode === 'height-backed-origin') {
584
+ modelTransform.preserveSourceAnchor = true
585
+ }
586
+
593
587
  if (!preserveAnchor) {
594
588
  modelTransform.ownerAnchorOffsetMil = offset
595
589
  modelTransform.offsetMil = { x: 0, y: 0, z: verticalOffset }
@@ -622,107 +616,6 @@ export class AltiumScene3dGeometricOwnerRecovery {
622
616
  }
623
617
  }
624
618
 
625
- /**
626
- * Corrects the source-frame half-turn for a four-pad tactile switch.
627
- * @param {object} placement External placement.
628
- * @param {object} component Resolved owner.
629
- * @param {object} body Source body.
630
- * @param {object | null} geometry Precomputed owner pad geometry.
631
- * @returns {object}
632
- */
633
- static #correctTactileYaw(placement, component, body, geometry) {
634
- const identity = [
635
- component?.designator,
636
- component?.description,
637
- component?.provenance?.footprintDescription
638
- ]
639
- .map((value) => String(value || ''))
640
- .join(' ')
641
- const sourceTilt = AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
642
- body?.modelRotationDeg?.x
643
- )
644
- const currentYaw = AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
645
- placement?.rotationDeg
646
- )
647
- const componentYaw =
648
- AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
649
- component?.rotation
650
- )
651
- const isTactileSwitch =
652
- /(?:^|[^a-z0-9])(?:tact|tactile|pushbutton)(?:$|[^a-z0-9])/i.test(
653
- identity
654
- ) &&
655
- AltiumScene3dGeometricOwnerRecovery.#hasTactileContactTopology(
656
- geometry
657
- )
658
-
659
- if (
660
- !isTactileSwitch ||
661
- (sourceTilt !== 90 && sourceTilt !== 270) ||
662
- currentYaw !== componentYaw
663
- ) {
664
- return placement
665
- }
666
-
667
- return {
668
- ...placement,
669
- rotationDeg: AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
670
- currentYaw + 180
671
- )
672
- }
673
- }
674
-
675
- /**
676
- * Detects a two-by-two tactile contact layout with two duplicated routed
677
- * contact pairs aligned along one footprint axis.
678
- * @param {object | null} geometry Precomputed owner pad geometry.
679
- * @returns {boolean}
680
- */
681
- static #hasTactileContactTopology(geometry) {
682
- if (
683
- geometry?.pads?.length !== 4 ||
684
- geometry.xCount !== 2 ||
685
- geometry.yCount !== 2
686
- ) {
687
- return false
688
- }
689
-
690
- const groups = new Map()
691
- for (const pad of geometry.localPads) {
692
- const sourcePad = pad.source
693
- const netName = String(sourcePad?.netName || '').trim()
694
- const netIndex = sourcePad?.netIndex
695
- const contactKey = netName
696
- ? `name:${netName}`
697
- : netIndex !== null &&
698
- netIndex !== undefined &&
699
- netIndex !== '' &&
700
- Number.isFinite(Number(netIndex))
701
- ? `index:${Number(netIndex)}`
702
- : ''
703
- if (!contactKey) return false
704
- groups.set(contactKey, [...(groups.get(contactKey) || []), pad])
705
- }
706
- if (
707
- groups.size !== 2 ||
708
- [...groups.values()].some((group) => group.length !== 2)
709
- ) {
710
- return false
711
- }
712
-
713
- return [...groups.values()].every((group) => {
714
- const sameX =
715
- AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
716
- group.map((pad) => Number(pad?.x || 0))
717
- ) === 1
718
- const sameY =
719
- AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
720
- group.map((pad) => Number(pad?.y || 0))
721
- ) === 1
722
- return sameX || sameY
723
- })
724
- }
725
-
726
619
  /**
727
620
  * Resolves a body vertical offset after a late owner recovery.
728
621
  * @param {object} body Source body.