altium-toolkit 1.1.26 → 1.1.31

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.
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Mirrors Altium PCB render models into the same bottom-view frame used by
3
+ * the 3D bottom preset.
4
+ */
5
+ export class AltiumPcbBottomViewMirror {
6
+ /**
7
+ * Mirrors a side-resolved Altium PCB model horizontally around the board.
8
+ * @param {object | null} documentModel Side-resolved PCB document model.
9
+ * @returns {object | null}
10
+ */
11
+ static apply(documentModel) {
12
+ const pcb = documentModel?.pcb
13
+ const outline = pcb?.boardOutline
14
+ if (!pcb || !outline) {
15
+ return documentModel || null
16
+ }
17
+
18
+ const mirrorX = AltiumPcbBottomViewMirror.#buildMirrorX(outline)
19
+
20
+ return {
21
+ ...documentModel,
22
+ pcb: {
23
+ ...pcb,
24
+ boardOutline: AltiumPcbBottomViewMirror.#mirrorOutline(
25
+ outline,
26
+ mirrorX
27
+ ),
28
+ polygons: AltiumPcbBottomViewMirror.#mirrorPolygons(
29
+ pcb.polygons,
30
+ mirrorX
31
+ ),
32
+ fills: AltiumPcbBottomViewMirror.#mirrorFills(
33
+ pcb.fills,
34
+ mirrorX
35
+ ),
36
+ tracks: AltiumPcbBottomViewMirror.#mirrorTracks(
37
+ pcb.tracks,
38
+ mirrorX
39
+ ),
40
+ arcs: AltiumPcbBottomViewMirror.#mirrorArcs(pcb.arcs, mirrorX),
41
+ regions: AltiumPcbBottomViewMirror.#mirrorRegions(
42
+ pcb.regions,
43
+ mirrorX
44
+ ),
45
+ shapeBasedRegions: AltiumPcbBottomViewMirror.#mirrorRegions(
46
+ pcb.shapeBasedRegions,
47
+ mirrorX
48
+ ),
49
+ boardRegions: AltiumPcbBottomViewMirror.#mirrorRegions(
50
+ pcb.boardRegions,
51
+ mirrorX
52
+ ),
53
+ vias: AltiumPcbBottomViewMirror.#mirrorVias(pcb.vias, mirrorX),
54
+ pads: AltiumPcbBottomViewMirror.#mirrorPads(pcb.pads, mirrorX),
55
+ texts: AltiumPcbBottomViewMirror.#mirrorTexts(pcb.texts),
56
+ textGroupTransform:
57
+ AltiumPcbBottomViewMirror.#buildTextGroupTransform(outline),
58
+ components: AltiumPcbBottomViewMirror.#mirrorComponents(
59
+ pcb.components,
60
+ mirrorX
61
+ )
62
+ }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Builds the board-space X-axis mirror function.
68
+ * @param {{ minX?: number, widthMil?: number }} outline Board outline.
69
+ * @returns {(value: unknown) => number}
70
+ */
71
+ static #buildMirrorX(outline) {
72
+ const minX = Number(outline?.minX || 0)
73
+ const maxX = minX + Number(outline?.widthMil || 0)
74
+
75
+ return (value) => minX + maxX - Number(value || 0)
76
+ }
77
+
78
+ /**
79
+ * Builds the SVG text-layer mirror used by the bottom 3D preset.
80
+ * @param {{ minX?: number, widthMil?: number }} outline Board outline.
81
+ * @returns {{ translateX: number, translateY: number, scaleX: number, scaleY: number }}
82
+ */
83
+ static #buildTextGroupTransform(outline) {
84
+ const minX = Number(outline?.minX || 0)
85
+ const maxX = minX + Number(outline?.widthMil || 0)
86
+
87
+ return {
88
+ translateX: minX + maxX,
89
+ translateY: 0,
90
+ scaleX: -1,
91
+ scaleY: 1
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Mirrors the board outline segment coordinates.
97
+ * @param {object} outline Board outline.
98
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
99
+ * @returns {object}
100
+ */
101
+ static #mirrorOutline(outline, mirrorX) {
102
+ return {
103
+ ...outline,
104
+ segments: AltiumPcbBottomViewMirror.#mirrorSegments(
105
+ outline?.segments,
106
+ mirrorX
107
+ )
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Mirrors polygon segment coordinates.
113
+ * @param {readonly object[] | undefined} polygons Polygon primitives.
114
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
115
+ * @returns {object[]}
116
+ */
117
+ static #mirrorPolygons(polygons, mirrorX) {
118
+ return AltiumPcbBottomViewMirror.#array(polygons).map((polygon) => ({
119
+ ...polygon,
120
+ segments: AltiumPcbBottomViewMirror.#mirrorSegments(
121
+ polygon?.segments,
122
+ mirrorX
123
+ )
124
+ }))
125
+ }
126
+
127
+ /**
128
+ * Mirrors path segment X coordinates.
129
+ * @param {readonly object[] | undefined} segments Path segments.
130
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
131
+ * @returns {object[]}
132
+ */
133
+ static #mirrorSegments(segments, mirrorX) {
134
+ return AltiumPcbBottomViewMirror.#array(segments).map((segment) => ({
135
+ ...segment,
136
+ x1: mirrorX(segment?.x1),
137
+ x2: mirrorX(segment?.x2),
138
+ ...(segment?.cx === null || segment?.cx === undefined
139
+ ? {}
140
+ : { cx: mirrorX(segment.cx) })
141
+ }))
142
+ }
143
+
144
+ /**
145
+ * Mirrors rectangular fill extents.
146
+ * @param {readonly object[] | undefined} fills Fill primitives.
147
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
148
+ * @returns {object[]}
149
+ */
150
+ static #mirrorFills(fills, mirrorX) {
151
+ return AltiumPcbBottomViewMirror.#array(fills).map((fill) => ({
152
+ ...fill,
153
+ x1: mirrorX(fill?.x1),
154
+ x2: mirrorX(fill?.x2),
155
+ points: AltiumPcbBottomViewMirror.#mirrorPointList(
156
+ fill?.points,
157
+ mirrorX
158
+ ),
159
+ holes: AltiumPcbBottomViewMirror.#mirrorHoleLists(
160
+ fill?.holes,
161
+ mirrorX
162
+ )
163
+ }))
164
+ }
165
+
166
+ /**
167
+ * Mirrors line track endpoints.
168
+ * @param {readonly object[] | undefined} tracks Track primitives.
169
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
170
+ * @returns {object[]}
171
+ */
172
+ static #mirrorTracks(tracks, mirrorX) {
173
+ return AltiumPcbBottomViewMirror.#array(tracks).map((track) => ({
174
+ ...track,
175
+ x1: mirrorX(track?.x1),
176
+ x2: mirrorX(track?.x2)
177
+ }))
178
+ }
179
+
180
+ /**
181
+ * Mirrors arc centers and angular spans.
182
+ * @param {readonly object[] | undefined} arcs Arc primitives.
183
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
184
+ * @returns {object[]}
185
+ */
186
+ static #mirrorArcs(arcs, mirrorX) {
187
+ return AltiumPcbBottomViewMirror.#array(arcs).map((arc) => ({
188
+ ...arc,
189
+ x: mirrorX(arc?.x),
190
+ startAngle: AltiumPcbBottomViewMirror.#mirrorAngleX(
191
+ arc?.startAngle
192
+ ),
193
+ endAngle: AltiumPcbBottomViewMirror.#mirrorAngleX(arc?.endAngle)
194
+ }))
195
+ }
196
+
197
+ /**
198
+ * Mirrors filled region contours and holes.
199
+ * @param {readonly object[] | undefined} regions Region primitives.
200
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
201
+ * @returns {object[]}
202
+ */
203
+ static #mirrorRegions(regions, mirrorX) {
204
+ return AltiumPcbBottomViewMirror.#array(regions).map((region) => ({
205
+ ...region,
206
+ points: AltiumPcbBottomViewMirror.#mirrorPointList(
207
+ region?.points,
208
+ mirrorX
209
+ ),
210
+ holes: AltiumPcbBottomViewMirror.#mirrorHoleLists(
211
+ region?.holes,
212
+ mirrorX
213
+ ),
214
+ ...(Array.isArray(region?.bendingLines)
215
+ ? {
216
+ bendingLines:
217
+ AltiumPcbBottomViewMirror.#mirrorBendingLines(
218
+ region.bendingLines,
219
+ mirrorX
220
+ )
221
+ }
222
+ : {})
223
+ }))
224
+ }
225
+
226
+ /**
227
+ * Mirrors via centers.
228
+ * @param {readonly object[] | undefined} vias Via primitives.
229
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
230
+ * @returns {object[]}
231
+ */
232
+ static #mirrorVias(vias, mirrorX) {
233
+ return AltiumPcbBottomViewMirror.#array(vias).map((via) => ({
234
+ ...via,
235
+ x: mirrorX(via?.x)
236
+ }))
237
+ }
238
+
239
+ /**
240
+ * Mirrors pad centers and X-axis local offsets.
241
+ * @param {readonly object[] | undefined} pads Pad primitives.
242
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
243
+ * @returns {object[]}
244
+ */
245
+ static #mirrorPads(pads, mirrorX) {
246
+ return AltiumPcbBottomViewMirror.#array(pads).map((pad) => ({
247
+ ...pad,
248
+ x: mirrorX(pad?.x),
249
+ rotation: AltiumPcbBottomViewMirror.#mirrorRotation(pad?.rotation),
250
+ holeRotation:
251
+ pad?.holeRotation === null || pad?.holeRotation === undefined
252
+ ? (pad?.holeRotation ?? null)
253
+ : AltiumPcbBottomViewMirror.#mirrorRotation(
254
+ pad.holeRotation
255
+ ),
256
+ ...(pad?.offsetTopX === null || pad?.offsetTopX === undefined
257
+ ? {}
258
+ : { offsetTopX: -Number(pad.offsetTopX || 0) })
259
+ }))
260
+ }
261
+
262
+ /**
263
+ * Preserves PCB text insertion points for the mirrored text layer.
264
+ * @param {readonly object[] | undefined} texts Text primitives.
265
+ * @returns {object[]}
266
+ */
267
+ static #mirrorTexts(texts) {
268
+ return AltiumPcbBottomViewMirror.#array(texts).map((text) => ({
269
+ ...text
270
+ }))
271
+ }
272
+
273
+ /**
274
+ * Mirrors component origins and package rotations.
275
+ * @param {readonly object[] | undefined} components Component records.
276
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
277
+ * @returns {object[]}
278
+ */
279
+ static #mirrorComponents(components, mirrorX) {
280
+ return AltiumPcbBottomViewMirror.#array(components).map(
281
+ (component) => ({
282
+ ...component,
283
+ x: mirrorX(component?.x),
284
+ rotation: AltiumPcbBottomViewMirror.#mirrorRotation(
285
+ component?.rotation
286
+ )
287
+ })
288
+ )
289
+ }
290
+
291
+ /**
292
+ * Mirrors a list of point objects.
293
+ * @param {readonly object[] | undefined} points Point list.
294
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
295
+ * @returns {object[]}
296
+ */
297
+ static #mirrorPointList(points, mirrorX) {
298
+ return AltiumPcbBottomViewMirror.#array(points).map((point) => ({
299
+ ...point,
300
+ x: mirrorX(point?.x),
301
+ ...(point?.centerX === null || point?.centerX === undefined
302
+ ? {}
303
+ : { centerX: mirrorX(point.centerX) }),
304
+ ...(point?.startAngle === null || point?.startAngle === undefined
305
+ ? {}
306
+ : {
307
+ startAngle: AltiumPcbBottomViewMirror.#mirrorAngleX(
308
+ point.startAngle
309
+ )
310
+ }),
311
+ ...(point?.endAngle === null || point?.endAngle === undefined
312
+ ? {}
313
+ : {
314
+ endAngle: AltiumPcbBottomViewMirror.#mirrorAngleX(
315
+ point.endAngle
316
+ )
317
+ })
318
+ }))
319
+ }
320
+
321
+ /**
322
+ * Mirrors nested hole point lists.
323
+ * @param {readonly object[][] | undefined} holes Hole point lists.
324
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
325
+ * @returns {object[][]}
326
+ */
327
+ static #mirrorHoleLists(holes, mirrorX) {
328
+ return AltiumPcbBottomViewMirror.#array(holes).map((hole) =>
329
+ AltiumPcbBottomViewMirror.#mirrorPointList(hole, mirrorX)
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Mirrors board-region bending-line X endpoints.
335
+ * @param {readonly object[] | undefined} bendingLines Bending lines.
336
+ * @param {(value: unknown) => number} mirrorX X-axis mirror function.
337
+ * @returns {object[]}
338
+ */
339
+ static #mirrorBendingLines(bendingLines, mirrorX) {
340
+ return AltiumPcbBottomViewMirror.#array(bendingLines).map((line) => ({
341
+ ...line,
342
+ x1:
343
+ line?.x1 === null || line?.x1 === undefined
344
+ ? (line?.x1 ?? null)
345
+ : mirrorX(line.x1),
346
+ x2:
347
+ line?.x2 === null || line?.x2 === undefined
348
+ ? (line?.x2 ?? null)
349
+ : mirrorX(line.x2)
350
+ }))
351
+ }
352
+
353
+ /**
354
+ * Mirrors an object rotation across the board X axis.
355
+ * @param {unknown} angle Rotation angle in degrees.
356
+ * @returns {number}
357
+ */
358
+ static #mirrorRotation(angle) {
359
+ return AltiumPcbBottomViewMirror.#normalizeAngle(
360
+ 180 - Number(angle || 0)
361
+ )
362
+ }
363
+
364
+ /**
365
+ * Mirrors a polar angle across the board X axis.
366
+ * @param {unknown} angle Angle in degrees.
367
+ * @returns {number}
368
+ */
369
+ static #mirrorAngleX(angle) {
370
+ return AltiumPcbBottomViewMirror.#normalizeAngle(
371
+ 180 - Number(angle || 0)
372
+ )
373
+ }
374
+
375
+ /**
376
+ * Normalizes one angle into the [0, 360) range.
377
+ * @param {number} angle Angle in degrees.
378
+ * @returns {number}
379
+ */
380
+ static #normalizeAngle(angle) {
381
+ const normalized = Number(angle || 0) % 360
382
+
383
+ return normalized < 0 ? normalized + 360 : normalized
384
+ }
385
+
386
+ /**
387
+ * Returns an array copy for transform operations.
388
+ * @param {readonly object[] | undefined} value Input collection.
389
+ * @returns {object[]}
390
+ */
391
+ static #array(value) {
392
+ return Array.isArray(value) ? Array.from(value) : []
393
+ }
394
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Preserves Altium component-body anchors that intentionally differ from the
3
+ * resolved footprint owner origin.
4
+ */
5
+ export class AltiumScene3dAuthoredBodyAnchorAdapter {
6
+ static #MIN_OWNER_OFFSET_MIL = 25
7
+ static #BODY_ANCHOR_TOLERANCE_MIL = 5
8
+ static #AUTHORED_SOURCE = 'authored-body-anchor'
9
+
10
+ /**
11
+ * Marks off-anchor explicit Altium body placements so the runtime does not
12
+ * recenter them by loaded model bounds.
13
+ * @param {object} sceneDescription Built scene description.
14
+ * @returns {object}
15
+ */
16
+ static apply(sceneDescription) {
17
+ if (
18
+ String(sceneDescription?.sourceFormat || '').toLowerCase() !==
19
+ 'altium' ||
20
+ !Array.isArray(sceneDescription?.externalPlacements)
21
+ ) {
22
+ return sceneDescription
23
+ }
24
+
25
+ const componentByDesignator =
26
+ AltiumScene3dAuthoredBodyAnchorAdapter.#componentByDesignator(
27
+ sceneDescription?.components
28
+ )
29
+ if (!componentByDesignator.size) {
30
+ return sceneDescription
31
+ }
32
+
33
+ let changed = false
34
+ const externalPlacements = sceneDescription.externalPlacements.map(
35
+ (placement) => {
36
+ const component = componentByDesignator.get(
37
+ String(placement?.designator || '')
38
+ )
39
+ if (
40
+ !AltiumScene3dAuthoredBodyAnchorAdapter.#shouldMarkPlacement(
41
+ placement,
42
+ component,
43
+ sceneDescription?.board
44
+ )
45
+ ) {
46
+ return placement
47
+ }
48
+
49
+ changed = true
50
+ return AltiumScene3dAuthoredBodyAnchorAdapter.#markPlacement(
51
+ placement,
52
+ component,
53
+ sceneDescription?.board
54
+ )
55
+ }
56
+ )
57
+
58
+ return changed
59
+ ? {
60
+ ...sceneDescription,
61
+ externalPlacements
62
+ }
63
+ : sceneDescription
64
+ }
65
+
66
+ /**
67
+ * Builds a designator lookup for scene components.
68
+ * @param {object[] | undefined} components Scene components.
69
+ * @returns {Map<string, object>}
70
+ */
71
+ static #componentByDesignator(components) {
72
+ return new Map(
73
+ (Array.isArray(components) ? components : [])
74
+ .map((component) => [
75
+ String(component?.designator || ''),
76
+ component
77
+ ])
78
+ .filter(([designator]) => designator)
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Checks whether one placement should bypass runtime pad-fallback
84
+ * recentering.
85
+ * @param {object} placement External placement.
86
+ * @param {object | undefined} component Matched scene component.
87
+ * @param {object | undefined} board Scene board.
88
+ * @returns {boolean}
89
+ */
90
+ static #shouldMarkPlacement(placement, component, board) {
91
+ if (
92
+ !component ||
93
+ String(placement?.projection?.source || '').toLowerCase() !==
94
+ 'pad-fallback' ||
95
+ !placement?.positionMil ||
96
+ !placement?.bodyPositionMil
97
+ ) {
98
+ return false
99
+ }
100
+
101
+ const bodyPosition = AltiumScene3dAuthoredBodyAnchorAdapter.#point(
102
+ placement.bodyPositionMil
103
+ )
104
+ const placementPosition =
105
+ AltiumScene3dAuthoredBodyAnchorAdapter.#absolutePlacementPosition(
106
+ placement,
107
+ board
108
+ )
109
+ const ownerPosition =
110
+ AltiumScene3dAuthoredBodyAnchorAdapter.#ownerPosition(
111
+ component,
112
+ board
113
+ )
114
+
115
+ if (
116
+ AltiumScene3dAuthoredBodyAnchorAdapter.#distance(
117
+ bodyPosition,
118
+ ownerPosition
119
+ ) < AltiumScene3dAuthoredBodyAnchorAdapter.#MIN_OWNER_OFFSET_MIL
120
+ ) {
121
+ return false
122
+ }
123
+
124
+ return (
125
+ AltiumScene3dAuthoredBodyAnchorAdapter.#distance(
126
+ placementPosition,
127
+ bodyPosition
128
+ ) <=
129
+ AltiumScene3dAuthoredBodyAnchorAdapter.#BODY_ANCHOR_TOLERANCE_MIL
130
+ )
131
+ }
132
+
133
+ /**
134
+ * Marks one placement as authored-anchor based.
135
+ * @param {object} placement External placement.
136
+ * @param {object} component Matched scene component.
137
+ * @param {object | undefined} board Scene board.
138
+ * @returns {object}
139
+ */
140
+ static #markPlacement(placement, component, board) {
141
+ return {
142
+ ...placement,
143
+ projection: {
144
+ ...(placement.projection || {}),
145
+ source: AltiumScene3dAuthoredBodyAnchorAdapter.#AUTHORED_SOURCE,
146
+ reason: 'Altium component body uses an authored model-origin anchor offset from the owner footprint.'
147
+ },
148
+ modelTransform: {
149
+ ...(placement.modelTransform || {}),
150
+ ownerAnchorOffsetMil:
151
+ AltiumScene3dAuthoredBodyAnchorAdapter.#ownerAnchorOffset(
152
+ placement,
153
+ component,
154
+ board
155
+ )
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Resolves the source body offset from its owner footprint anchor.
162
+ * @param {object} placement External placement.
163
+ * @param {object} component Matched scene component.
164
+ * @param {object | undefined} board Scene board.
165
+ * @returns {{ x: number, y: number }}
166
+ */
167
+ static #ownerAnchorOffset(placement, component, board) {
168
+ const bodyPosition = AltiumScene3dAuthoredBodyAnchorAdapter.#point(
169
+ placement.bodyPositionMil
170
+ )
171
+ const ownerPosition =
172
+ AltiumScene3dAuthoredBodyAnchorAdapter.#ownerPosition(
173
+ component,
174
+ board
175
+ )
176
+
177
+ return {
178
+ x: bodyPosition.x - ownerPosition.x,
179
+ y: bodyPosition.y - ownerPosition.y
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Resolves a placement position in board coordinates.
185
+ * @param {object} placement External placement.
186
+ * @param {object | undefined} board Scene board.
187
+ * @returns {{ x: number, y: number }}
188
+ */
189
+ static #absolutePlacementPosition(placement, board) {
190
+ return {
191
+ x:
192
+ Number(placement?.positionMil?.x || 0) +
193
+ Number(board?.centerX || 0),
194
+ y:
195
+ Number(placement?.positionMil?.y || 0) +
196
+ Number(board?.centerY || 0)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Resolves a scene component position in board coordinates.
202
+ * @param {object} component Scene component.
203
+ * @param {object | undefined} board Scene board.
204
+ * @returns {{ x: number, y: number }}
205
+ */
206
+ static #ownerPosition(component, board) {
207
+ const boardPosition = component?.boardPositionMil
208
+ if (
209
+ Number.isFinite(Number(boardPosition?.x)) &&
210
+ Number.isFinite(Number(boardPosition?.y))
211
+ ) {
212
+ return {
213
+ x: Number(boardPosition.x),
214
+ y: Number(boardPosition.y)
215
+ }
216
+ }
217
+
218
+ return {
219
+ x:
220
+ Number(component?.positionMil?.x || 0) +
221
+ Number(board?.centerX || 0),
222
+ y:
223
+ Number(component?.positionMil?.y || 0) +
224
+ Number(board?.centerY || 0)
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Normalizes a partial point.
230
+ * @param {object | undefined} point Source point.
231
+ * @returns {{ x: number, y: number }}
232
+ */
233
+ static #point(point) {
234
+ return {
235
+ x: Number(point?.x || 0),
236
+ y: Number(point?.y || 0)
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Measures XY distance between two points.
242
+ * @param {{ x: number, y: number }} first First point.
243
+ * @param {{ x: number, y: number }} second Second point.
244
+ * @returns {number}
245
+ */
246
+ static #distance(first, second) {
247
+ return Math.hypot(
248
+ Number(first?.x || 0) - Number(second?.x || 0),
249
+ Number(first?.y || 0) - Number(second?.y || 0)
250
+ )
251
+ }
252
+ }