altium-toolkit 1.1.36 → 1.1.38

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/AGENTS.md CHANGED
@@ -40,6 +40,21 @@
40
40
  - Keep parser and renderer fixes universal. Never special-case a specific file
41
41
  name, project identifier, fixture helper, or source-derived phrase.
42
42
 
43
+ ## Fix Quality Rules
44
+
45
+ - Fixes must always address the general behavior, not a specific example,
46
+ fixture, file, project, or test case.
47
+ - Do not implement workarounds, cheats, allowlists, hard-coded example handling,
48
+ or special-case logic to make one sample pass.
49
+ - When you encounter existing workaround code, cheating behavior, or
50
+ example-specific handling, rewrite it into general-purpose behavior
51
+ immediately when it is in scope for the change.
52
+ - Keep fixes universal and structural: derive behavior from the underlying data
53
+ model, format, protocol, or UI contract instead of matching known sample text,
54
+ filenames, labels, or project identifiers.
55
+ - After fixing code, run the appropriate repo-owned tests and do not modify tests
56
+ just to make a workaround pass.
57
+
43
58
  ## Testing Guidelines
44
59
 
45
60
  - Use repo scripts only: `npm test`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.1.36",
3
+ "version": "1.1.38",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -35,6 +35,13 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
35
35
  return sceneDescription
36
36
  }
37
37
 
38
+ const repeatedPlacementKeys =
39
+ AltiumScene3dAuthoredBodyAnchorAdapter.#repeatedPlacementKeys(
40
+ sceneDescription.externalPlacements
41
+ )
42
+ const pads = Array.isArray(sceneDescription?.detail?.pads)
43
+ ? sceneDescription.detail.pads
44
+ : []
38
45
  let changed = false
39
46
  const externalPlacements = sceneDescription.externalPlacements.map(
40
47
  (placement) => {
@@ -52,10 +59,18 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
52
59
  }
53
60
 
54
61
  changed = true
62
+ const preservesSourceOriginRepair =
63
+ AltiumScene3dAuthoredBodyAnchorAdapter.#isRepeatedOwnedDrilledPadAnchor(
64
+ placement,
65
+ component,
66
+ pads,
67
+ repeatedPlacementKeys
68
+ )
55
69
  return AltiumScene3dAuthoredBodyAnchorAdapter.#markPlacement(
56
70
  placement,
57
71
  component,
58
- sceneDescription?.board
72
+ sceneDescription?.board,
73
+ { preservesSourceOriginRepair }
59
74
  )
60
75
  }
61
76
  )
@@ -84,6 +99,55 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
84
99
  )
85
100
  }
86
101
 
102
+ /**
103
+ * Finds repeated external-model identities within one built scene.
104
+ * @param {object[]} placements Scene external placements.
105
+ * @returns {Set<string>}
106
+ */
107
+ static #repeatedPlacementKeys(placements) {
108
+ const counts = new Map()
109
+ const placementList = Array.isArray(placements) ? placements : []
110
+
111
+ placementList.forEach((placement) => {
112
+ const key =
113
+ AltiumScene3dAuthoredBodyAnchorAdapter.#placementIdentityKey(
114
+ placement
115
+ )
116
+ if (!key) {
117
+ return
118
+ }
119
+
120
+ counts.set(key, Number(counts.get(key) || 0) + 1)
121
+ })
122
+
123
+ return new Set(
124
+ [...counts.entries()]
125
+ .filter(([, count]) => count > 1)
126
+ .map(([key]) => key)
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Builds a stable model-placement identity for repeated body detection.
132
+ * @param {object} placement External placement.
133
+ * @returns {string}
134
+ */
135
+ static #placementIdentityKey(placement) {
136
+ const designator = String(placement?.designator || '').trim()
137
+ const model = placement?.externalModel || {}
138
+ const modelParts = [
139
+ model?.origin,
140
+ model?.sourceStream,
141
+ model?.relativePath,
142
+ model?.name,
143
+ model?.format
144
+ ].map((value) => String(value || '').trim())
145
+
146
+ return designator && modelParts.some(Boolean)
147
+ ? [designator, ...modelParts].join('::')
148
+ : ''
149
+ }
150
+
87
151
  /**
88
152
  * Checks whether one placement should bypass runtime pad-fallback
89
153
  * recentering.
@@ -97,6 +161,7 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
97
161
  !component ||
98
162
  String(placement?.projection?.source || '').toLowerCase() !==
99
163
  'pad-fallback' ||
164
+ placement?.projection?.preservePadFallbackCentering ||
100
165
  !placement?.positionMil ||
101
166
  !placement?.bodyPositionMil
102
167
  ) {
@@ -206,33 +271,189 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
206
271
  .join(' ')
207
272
  }
208
273
 
274
+ /**
275
+ * Checks whether one placement is one member of a repeated drilled-pad
276
+ * body set whose source-origin repair must remain enabled.
277
+ * @param {object} placement External placement.
278
+ * @param {object | undefined} component Matched scene component.
279
+ * @param {object[]} pads Scene detail pads.
280
+ * @param {Set<string>} repeatedPlacementKeys Repeated placement keys.
281
+ * @returns {boolean}
282
+ */
283
+ static #isRepeatedOwnedDrilledPadAnchor(
284
+ placement,
285
+ component,
286
+ pads,
287
+ repeatedPlacementKeys
288
+ ) {
289
+ const key =
290
+ AltiumScene3dAuthoredBodyAnchorAdapter.#placementIdentityKey(
291
+ placement
292
+ )
293
+
294
+ return (
295
+ repeatedPlacementKeys.has(key) &&
296
+ AltiumScene3dAuthoredBodyAnchorAdapter.#isOwnedDrilledPadAnchor(
297
+ placement,
298
+ component,
299
+ pads
300
+ )
301
+ )
302
+ }
303
+
304
+ /**
305
+ * Checks whether the body anchor sits inside a drilled pad owned by the
306
+ * resolved component.
307
+ * @param {object} placement External placement.
308
+ * @param {object | undefined} component Matched scene component.
309
+ * @param {object[]} pads Scene detail pads.
310
+ * @returns {boolean}
311
+ */
312
+ static #isOwnedDrilledPadAnchor(placement, component, pads) {
313
+ const componentIndex = Number(component?.componentIndex)
314
+ if (!Number.isFinite(componentIndex)) {
315
+ return false
316
+ }
317
+
318
+ const bodyPosition = placement?.bodyPositionMil
319
+ if (
320
+ !AltiumScene3dAuthoredBodyAnchorAdapter.#hasFinitePoint(
321
+ bodyPosition
322
+ )
323
+ ) {
324
+ return false
325
+ }
326
+
327
+ return (Array.isArray(pads) ? pads : []).some(
328
+ (pad) =>
329
+ Number(pad?.componentIndex) === componentIndex &&
330
+ AltiumScene3dAuthoredBodyAnchorAdapter.#hasDrilledPadOpening(
331
+ pad
332
+ ) &&
333
+ AltiumScene3dAuthoredBodyAnchorAdapter.#padContainsPoint(
334
+ pad,
335
+ bodyPosition
336
+ )
337
+ )
338
+ }
339
+
340
+ /**
341
+ * Checks whether a pad contains a drilled or slotted board opening.
342
+ * @param {object} pad Scene detail pad.
343
+ * @returns {boolean}
344
+ */
345
+ static #hasDrilledPadOpening(pad) {
346
+ const holeGeometry = pad?.holeGeometry || {}
347
+
348
+ return [
349
+ pad?.holeDiameter,
350
+ pad?.drillDiameter,
351
+ pad?.holeSize,
352
+ pad?.holeSlotLength,
353
+ pad?.slotLength,
354
+ holeGeometry?.diameter,
355
+ holeGeometry?.length,
356
+ holeGeometry?.slotLength
357
+ ].some((value) => Number(value || 0) > 0)
358
+ }
359
+
360
+ /**
361
+ * Checks whether one XY point falls inside the effective pad anchor span.
362
+ * @param {object} pad Scene detail pad.
363
+ * @param {{ x?: number, y?: number }} point Board-space point.
364
+ * @returns {boolean}
365
+ */
366
+ static #padContainsPoint(pad, point) {
367
+ if (!AltiumScene3dAuthoredBodyAnchorAdapter.#hasFinitePoint(pad)) {
368
+ return false
369
+ }
370
+
371
+ const radius =
372
+ AltiumScene3dAuthoredBodyAnchorAdapter.#padAnchorRadiusMil(pad)
373
+ return (
374
+ radius > 0 &&
375
+ AltiumScene3dAuthoredBodyAnchorAdapter.#distance(pad, point) <=
376
+ radius +
377
+ AltiumScene3dAuthoredBodyAnchorAdapter
378
+ .#BODY_ANCHOR_TOLERANCE_MIL
379
+ )
380
+ }
381
+
382
+ /**
383
+ * Resolves the effective XY radius around a drilled pad center.
384
+ * @param {object} pad Scene detail pad.
385
+ * @returns {number}
386
+ */
387
+ static #padAnchorRadiusMil(pad) {
388
+ const holeGeometry = pad?.holeGeometry || {}
389
+ const diameter = Math.max(
390
+ Number(pad?.sizeTopX || 0),
391
+ Number(pad?.sizeTopY || 0),
392
+ Number(pad?.sizeMidX || 0),
393
+ Number(pad?.sizeMidY || 0),
394
+ Number(pad?.sizeBottomX || 0),
395
+ Number(pad?.sizeBottomY || 0),
396
+ Number(pad?.holeDiameter || 0),
397
+ Number(pad?.drillDiameter || 0),
398
+ Number(pad?.holeSize || 0),
399
+ Number(pad?.holeSlotLength || 0),
400
+ Number(pad?.slotLength || 0),
401
+ Number(holeGeometry?.diameter || 0),
402
+ Number(holeGeometry?.length || 0),
403
+ Number(holeGeometry?.slotLength || 0)
404
+ )
405
+
406
+ return Number.isFinite(diameter) && diameter > 0 ? diameter / 2 : 0
407
+ }
408
+
209
409
  /**
210
410
  * Marks one placement as authored-anchor based.
211
411
  * @param {object} placement External placement.
212
412
  * @param {object} component Matched scene component.
213
413
  * @param {object | undefined} board Scene board.
414
+ * @param {{ preservesSourceOriginRepair?: boolean }} [options] Marking options.
214
415
  * @returns {object}
215
416
  */
216
- static #markPlacement(placement, component, board) {
417
+ static #markPlacement(placement, component, board, options = {}) {
418
+ const reason = options.preservesSourceOriginRepair
419
+ ? 'Altium repeated component body is anchored in an owned drilled pad, so the runtime preserves the body anchor while allowing embedded source-origin repair.'
420
+ : 'Altium component body uses an authored model-origin anchor offset from the owner footprint.'
421
+
217
422
  return {
218
423
  ...placement,
219
424
  projection: {
220
425
  ...(placement.projection || {}),
221
426
  source: AltiumScene3dAuthoredBodyAnchorAdapter.#AUTHORED_SOURCE,
222
- reason: 'Altium component body uses an authored model-origin anchor offset from the owner footprint.'
427
+ reason
223
428
  },
224
- modelTransform: {
225
- ...(placement.modelTransform || {}),
226
- ownerAnchorOffsetMil:
227
- AltiumScene3dAuthoredBodyAnchorAdapter.#ownerAnchorOffset(
228
- placement,
229
- component,
230
- board
231
- )
232
- }
429
+ modelTransform: options.preservesSourceOriginRepair
430
+ ? AltiumScene3dAuthoredBodyAnchorAdapter.#withoutOwnerAnchorOffset(
431
+ placement.modelTransform
432
+ )
433
+ : {
434
+ ...(placement.modelTransform || {}),
435
+ ownerAnchorOffsetMil:
436
+ AltiumScene3dAuthoredBodyAnchorAdapter.#ownerAnchorOffset(
437
+ placement,
438
+ component,
439
+ board
440
+ )
441
+ }
233
442
  }
234
443
  }
235
444
 
445
+ /**
446
+ * Removes owner-anchor metadata while preserving renderable transforms.
447
+ * @param {object | null | undefined} modelTransform Placement transform.
448
+ * @returns {object}
449
+ */
450
+ static #withoutOwnerAnchorOffset(modelTransform) {
451
+ const { ownerAnchorOffsetMil, ...renderTransform } =
452
+ modelTransform || {}
453
+
454
+ return renderTransform
455
+ }
456
+
236
457
  /**
237
458
  * Resolves the source body offset from its owner footprint anchor.
238
459
  * @param {object} placement External placement.
@@ -313,6 +534,18 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
313
534
  }
314
535
  }
315
536
 
537
+ /**
538
+ * Checks whether a value has finite XY coordinates.
539
+ * @param {object | undefined} point Source point.
540
+ * @returns {boolean}
541
+ */
542
+ static #hasFinitePoint(point) {
543
+ return (
544
+ Number.isFinite(Number(point?.x)) &&
545
+ Number.isFinite(Number(point?.y))
546
+ )
547
+ }
548
+
316
549
  /**
317
550
  * Measures XY distance between two points.
318
551
  * @param {{ x: number, y: number }} first First point.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Decides when explicit Altium connector-body yaw should win over footprint
3
+ * rotation for off-anchor 3D bodies.
4
+ */
5
+ export class AltiumScene3dAuthoredConnectorYawPolicy {
6
+ static #CONNECTOR_OWNER_PATTERN =
7
+ /(?:pin\s*header|pinheader|header|connector|socket|fpc|flex|jtag)/i
8
+
9
+ /**
10
+ * Checks whether an offset connector body should keep Altium's authored
11
+ * body yaw instead of adopting the footprint yaw.
12
+ * @param {{ placement?: object, component?: object | null, pads?: object[], ownerOffsetToleranceMil?: number }} context Placement context.
13
+ * @returns {boolean}
14
+ */
15
+ static shouldPreserve(context) {
16
+ const placement = context?.placement
17
+ const component = context?.component
18
+ const ownerOffsetToleranceMil = Number(
19
+ context?.ownerOffsetToleranceMil || 0
20
+ )
21
+
22
+ if (
23
+ !component ||
24
+ !AltiumScene3dAuthoredConnectorYawPolicy.#supportsProjection(
25
+ placement
26
+ ) ||
27
+ AltiumScene3dAuthoredConnectorYawPolicy.#distanceToBody(
28
+ placement,
29
+ component
30
+ ) <= ownerOffsetToleranceMil ||
31
+ !AltiumScene3dAuthoredConnectorYawPolicy.#hasConnectorOwnerIdentity(
32
+ component
33
+ )
34
+ ) {
35
+ return false
36
+ }
37
+
38
+ return AltiumScene3dAuthoredConnectorYawPolicy.#hasSingleRowPadGeometry(
39
+ component,
40
+ context?.pads
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Checks whether one projection can carry an authored off-center connector
46
+ * source yaw.
47
+ * @param {object | null | undefined} placement External placement.
48
+ * @returns {boolean}
49
+ */
50
+ static #supportsProjection(placement) {
51
+ const source = String(placement?.projection?.source || '').toLowerCase()
52
+
53
+ return source === 'pad-fallback' || source === 'model-bounds'
54
+ }
55
+
56
+ /**
57
+ * Checks whether component metadata describes connector/header hardware.
58
+ * @param {object} component PCB component.
59
+ * @returns {boolean}
60
+ */
61
+ static #hasConnectorOwnerIdentity(component) {
62
+ return AltiumScene3dAuthoredConnectorYawPolicy.#CONNECTOR_OWNER_PATTERN.test(
63
+ [
64
+ component?.designator,
65
+ component?.pattern,
66
+ component?.source,
67
+ component?.description,
68
+ ...Object.values(component?.parameters || {})
69
+ ]
70
+ .map((value) => String(value || ''))
71
+ .join(' ')
72
+ )
73
+ }
74
+
75
+ /**
76
+ * Checks whether owned pads form one long connector row.
77
+ * @param {object} component PCB component.
78
+ * @param {object[] | undefined} pads Source PCB pads.
79
+ * @returns {boolean}
80
+ */
81
+ static #hasSingleRowPadGeometry(component, pads) {
82
+ const measurablePads =
83
+ AltiumScene3dAuthoredConnectorYawPolicy.#componentPads(
84
+ component,
85
+ pads
86
+ ).filter((pad) =>
87
+ AltiumScene3dAuthoredConnectorYawPolicy.#isMeasurablePad(pad)
88
+ )
89
+ if (measurablePads.length < 3) {
90
+ return false
91
+ }
92
+
93
+ const xs = measurablePads.map((pad) => Number(pad?.x || 0))
94
+ const ys = measurablePads.map((pad) => Number(pad?.y || 0))
95
+ const spreadX = Math.max(...xs) - Math.min(...xs)
96
+ const spreadY = Math.max(...ys) - Math.min(...ys)
97
+ const majorSpread = Math.max(spreadX, spreadY)
98
+ const minorSpread = Math.min(spreadX, spreadY)
99
+ const maxPadSpan = Math.max(
100
+ ...measurablePads.map((pad) =>
101
+ Math.max(
102
+ Number(pad?.sizeTopX || 0),
103
+ Number(pad?.sizeTopY || 0),
104
+ Number(pad?.sizeMidX || 0),
105
+ Number(pad?.sizeMidY || 0),
106
+ Number(pad?.sizeBottomX || 0),
107
+ Number(pad?.sizeBottomY || 0)
108
+ )
109
+ )
110
+ )
111
+
112
+ return (
113
+ majorSpread >= Math.max(100, maxPadSpan * 3) &&
114
+ minorSpread <= Math.max(10, maxPadSpan * 1.25)
115
+ )
116
+ }
117
+
118
+ /**
119
+ * Returns pads owned by one component index.
120
+ * @param {object} component Owning component.
121
+ * @param {object[] | undefined} pads Source PCB pads.
122
+ * @returns {object[]}
123
+ */
124
+ static #componentPads(component, pads) {
125
+ const componentIndex = Number(component?.componentIndex)
126
+ if (!Number.isFinite(componentIndex)) {
127
+ return []
128
+ }
129
+
130
+ return (Array.isArray(pads) ? pads : []).filter(
131
+ (pad) => Number(pad?.componentIndex) === componentIndex
132
+ )
133
+ }
134
+
135
+ /**
136
+ * Checks whether one pad has finite coordinates and non-zero dimensions.
137
+ * @param {object} pad Source PCB pad.
138
+ * @returns {boolean}
139
+ */
140
+ static #isMeasurablePad(pad) {
141
+ const width = Math.max(
142
+ Number(pad?.sizeTopX || 0),
143
+ Number(pad?.sizeMidX || 0),
144
+ Number(pad?.sizeBottomX || 0)
145
+ )
146
+ const depth = Math.max(
147
+ Number(pad?.sizeTopY || 0),
148
+ Number(pad?.sizeMidY || 0),
149
+ Number(pad?.sizeBottomY || 0)
150
+ )
151
+
152
+ return (
153
+ Number.isFinite(Number(pad?.x)) &&
154
+ Number.isFinite(Number(pad?.y)) &&
155
+ width > 0 &&
156
+ depth > 0
157
+ )
158
+ }
159
+
160
+ /**
161
+ * Measures planar distance from placement body anchor to component origin.
162
+ * @param {object} placement External model placement.
163
+ * @param {object} component PCB component.
164
+ * @returns {number}
165
+ */
166
+ static #distanceToBody(placement, component) {
167
+ const dx =
168
+ Number(placement?.bodyPositionMil?.x || 0) -
169
+ Number(component?.x || 0)
170
+ const dy =
171
+ Number(placement?.bodyPositionMil?.y || 0) -
172
+ Number(component?.y || 0)
173
+
174
+ return Math.hypot(dx, dy)
175
+ }
176
+ }
@@ -0,0 +1,93 @@
1
+ const BOTTOM_SOURCE_HALF_TURN_PACKAGE_PATTERN =
2
+ /(?:^|[^a-z0-9])(?:[a-z0-9]*qfn[a-z0-9]*|[a-z0-9]*dfn[a-z0-9]*)(?:$|[^a-z0-9])/i
3
+
4
+ /**
5
+ * Resolves whether a bottom-side source-model half-turn is part of the
6
+ * package's contact-side frame and must survive mount-side normalization.
7
+ */
8
+ export class AltiumScene3dBottomSourceHalfTurnPolicy {
9
+ /**
10
+ * Checks whether a bottom-side model X half-turn should be preserved.
11
+ * @param {{ component?: object | null, componentBody?: object | null, placement?: object | null, modelTransform?: object | null }} context Placement context.
12
+ * @returns {boolean}
13
+ */
14
+ static shouldPreserve(context = {}) {
15
+ if (
16
+ !AltiumScene3dBottomSourceHalfTurnPolicy.#hasSourceHalfTurn(context)
17
+ ) {
18
+ return false
19
+ }
20
+
21
+ return BOTTOM_SOURCE_HALF_TURN_PACKAGE_PATTERN.test(
22
+ AltiumScene3dBottomSourceHalfTurnPolicy.#packageIdentityText(
23
+ context
24
+ )
25
+ )
26
+ }
27
+
28
+ /**
29
+ * Checks whether source data carries an X-axis half-turn.
30
+ * @param {{ componentBody?: object | null, modelTransform?: object | null }} context Placement context.
31
+ * @returns {boolean}
32
+ */
33
+ static #hasSourceHalfTurn(context) {
34
+ const sourceRotation = context?.componentBody?.modelRotationDeg
35
+ const renderRotation = context?.modelTransform?.rotationDeg
36
+ const sourceX =
37
+ sourceRotation?.x !== undefined
38
+ ? sourceRotation.x
39
+ : renderRotation?.x
40
+
41
+ return (
42
+ AltiumScene3dBottomSourceHalfTurnPolicy.#normalizeAngle(sourceX) ===
43
+ 180
44
+ )
45
+ }
46
+
47
+ /**
48
+ * Builds identity text from the component, source body, and placement.
49
+ * @param {{ component?: object | null, componentBody?: object | null, placement?: object | null }} context Placement context.
50
+ * @returns {string}
51
+ */
52
+ static #packageIdentityText(context) {
53
+ const component = context?.component || {}
54
+ const componentBody = context?.componentBody || {}
55
+ const placement = context?.placement || {}
56
+
57
+ return [
58
+ component?.pattern,
59
+ component?.source,
60
+ component?.description,
61
+ ...AltiumScene3dBottomSourceHalfTurnPolicy.#recordValues(
62
+ component?.parameters
63
+ ),
64
+ componentBody?.identifier,
65
+ componentBody?.modelId,
66
+ componentBody?.name,
67
+ placement?.externalModel?.name
68
+ ]
69
+ .map((value) => String(value || ''))
70
+ .join(' ')
71
+ }
72
+
73
+ /**
74
+ * Resolves stringable values from one optional metadata record.
75
+ * @param {Record<string, unknown> | null | undefined} record Source record.
76
+ * @returns {unknown[]}
77
+ */
78
+ static #recordValues(record) {
79
+ return record && typeof record === 'object' ? Object.values(record) : []
80
+ }
81
+
82
+ /**
83
+ * Normalizes one angle into the positive 0-359 degree range.
84
+ * @param {unknown} angle Source angle.
85
+ * @returns {number}
86
+ */
87
+ static #normalizeAngle(angle) {
88
+ const numericAngle = Number(angle || 0)
89
+ const normalized = numericAngle % 360
90
+
91
+ return normalized < 0 ? normalized + 360 : normalized
92
+ }
93
+ }