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.
@@ -0,0 +1,453 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds overlay fill cutouts from drilled pad and via metadata.
7
+ */
8
+ export class PcbScene3dDrillCutoutBuilder {
9
+ static #CIRCLE_SEGMENTS = 24
10
+ static #SLOT_CAP_SEGMENTS = 12
11
+ static #EPSILON = 0.001
12
+
13
+ /**
14
+ * Adds drill-shaped holes to every fill intersected by a pad or via drill.
15
+ * @param {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]} fills
16
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
17
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
18
+ * @returns {{ points?: { x: number, y: number }[], holes?: { x: number, y: number }[][], x1?: number, y1?: number, x2?: number, y2?: number }[]}
19
+ */
20
+ static clipFills(fills, pads, vias) {
21
+ const cutouts = PcbScene3dDrillCutoutBuilder.#buildCutouts(pads, vias)
22
+
23
+ if (!cutouts.length) {
24
+ return fills
25
+ }
26
+
27
+ return fills.map((fill) => {
28
+ const holes = cutouts
29
+ .filter((cutout) =>
30
+ PcbScene3dDrillCutoutBuilder.#cutoutTouchesFill(
31
+ cutout,
32
+ fill
33
+ )
34
+ )
35
+ .map((cutout) => cutout.points)
36
+
37
+ if (!holes.length) {
38
+ return fill
39
+ }
40
+
41
+ return {
42
+ ...fill,
43
+ holes: [
44
+ ...PcbScene3dDrillCutoutBuilder.#resolveExistingHoles(fill),
45
+ ...holes
46
+ ]
47
+ }
48
+ })
49
+ }
50
+
51
+ /**
52
+ * Builds all known drill cutout contours.
53
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
54
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
55
+ * @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
56
+ */
57
+ static #buildCutouts(pads, vias) {
58
+ return [
59
+ ...PcbScene3dDrillCutoutBuilder.#buildPadCutouts(pads),
60
+ ...PcbScene3dDrillCutoutBuilder.#buildViaCutouts(vias)
61
+ ]
62
+ }
63
+
64
+ /**
65
+ * Builds drill contours for drilled pads.
66
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number, holeSlotLength?: number, slotLength?: number, rotation?: number, holeRotation?: number }[]} pads
67
+ * @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
68
+ */
69
+ static #buildPadCutouts(pads) {
70
+ return (Array.isArray(pads) ? pads : [])
71
+ .map((pad) => {
72
+ const x = Number(pad?.x)
73
+ const y = Number(pad?.y)
74
+ const diameter =
75
+ PcbScene3dDrillCutoutBuilder.#resolvePositiveNumber(pad, [
76
+ 'holeDiameter',
77
+ 'drillDiameter'
78
+ ])
79
+ const slotLength =
80
+ PcbScene3dDrillCutoutBuilder.#resolvePositiveNumber(pad, [
81
+ 'holeSlotLength',
82
+ 'slotLength'
83
+ ])
84
+ const rotationDeg =
85
+ Number(pad?.rotation || 0) + Number(pad?.holeRotation || 0)
86
+
87
+ return PcbScene3dDrillCutoutBuilder.#buildCutout(
88
+ x,
89
+ y,
90
+ diameter,
91
+ slotLength,
92
+ rotationDeg
93
+ )
94
+ })
95
+ .filter(Boolean)
96
+ }
97
+
98
+ /**
99
+ * Builds circular drill contours for vias.
100
+ * @param {{ x?: number, y?: number, holeDiameter?: number, drillDiameter?: number }[]} vias
101
+ * @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }[]}
102
+ */
103
+ static #buildViaCutouts(vias) {
104
+ return (Array.isArray(vias) ? vias : [])
105
+ .map((via) => {
106
+ const x = Number(via?.x)
107
+ const y = Number(via?.y)
108
+ const diameter =
109
+ PcbScene3dDrillCutoutBuilder.#resolvePositiveNumber(via, [
110
+ 'holeDiameter',
111
+ 'drillDiameter'
112
+ ])
113
+
114
+ return PcbScene3dDrillCutoutBuilder.#buildCutout(
115
+ x,
116
+ y,
117
+ diameter,
118
+ 0,
119
+ 0
120
+ )
121
+ })
122
+ .filter(Boolean)
123
+ }
124
+
125
+ /**
126
+ * Builds one drill contour from center, diameter, and optional slot length.
127
+ * @param {number} x
128
+ * @param {number} y
129
+ * @param {number} diameter
130
+ * @param {number} slotLength
131
+ * @param {number} rotationDeg
132
+ * @returns {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] } | null}
133
+ */
134
+ static #buildCutout(x, y, diameter, slotLength, rotationDeg) {
135
+ if (
136
+ !Number.isFinite(x) ||
137
+ !Number.isFinite(y) ||
138
+ !Number.isFinite(diameter) ||
139
+ diameter <= PcbScene3dDrillCutoutBuilder.#EPSILON
140
+ ) {
141
+ return null
142
+ }
143
+
144
+ const points =
145
+ Number.isFinite(slotLength) && slotLength > diameter
146
+ ? PcbScene3dDrillCutoutBuilder.#buildSlotPoints(
147
+ x,
148
+ y,
149
+ diameter,
150
+ slotLength,
151
+ rotationDeg
152
+ )
153
+ : PcbScene3dDrillCutoutBuilder.#buildCirclePoints(
154
+ x,
155
+ y,
156
+ diameter
157
+ )
158
+
159
+ return {
160
+ x,
161
+ y,
162
+ points,
163
+ bounds: PcbScene3dDrillCutoutBuilder.#resolvePointBounds(points)
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Builds a polygonal circular drill contour.
169
+ * @param {number} x
170
+ * @param {number} y
171
+ * @param {number} diameter
172
+ * @returns {{ x: number, y: number }[]}
173
+ */
174
+ static #buildCirclePoints(x, y, diameter) {
175
+ const radius = diameter / 2
176
+
177
+ return Array.from(
178
+ { length: PcbScene3dDrillCutoutBuilder.#CIRCLE_SEGMENTS },
179
+ (_, index) => {
180
+ const angle =
181
+ (Math.PI * 2 * index) /
182
+ PcbScene3dDrillCutoutBuilder.#CIRCLE_SEGMENTS
183
+
184
+ return {
185
+ x: x + Math.cos(angle) * radius,
186
+ y: y + Math.sin(angle) * radius
187
+ }
188
+ }
189
+ )
190
+ }
191
+
192
+ /**
193
+ * Builds a polygonal slotted drill contour.
194
+ * @param {number} x
195
+ * @param {number} y
196
+ * @param {number} diameter
197
+ * @param {number} slotLength
198
+ * @param {number} rotationDeg
199
+ * @returns {{ x: number, y: number }[]}
200
+ */
201
+ static #buildSlotPoints(x, y, diameter, slotLength, rotationDeg) {
202
+ const radius = diameter / 2
203
+ const halfStraight = Math.max((slotLength - diameter) / 2, 0)
204
+ const rotation = (rotationDeg * Math.PI) / 180
205
+ const points = []
206
+
207
+ for (
208
+ let index = 0;
209
+ index <= PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS;
210
+ index += 1
211
+ ) {
212
+ const angle =
213
+ -Math.PI / 2 +
214
+ (Math.PI * index) /
215
+ PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS
216
+ points.push(
217
+ PcbScene3dDrillCutoutBuilder.#rotatePoint(
218
+ x,
219
+ y,
220
+ halfStraight + Math.cos(angle) * radius,
221
+ Math.sin(angle) * radius,
222
+ rotation
223
+ )
224
+ )
225
+ }
226
+
227
+ for (
228
+ let index = 0;
229
+ index <= PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS;
230
+ index += 1
231
+ ) {
232
+ const angle =
233
+ Math.PI / 2 +
234
+ (Math.PI * index) /
235
+ PcbScene3dDrillCutoutBuilder.#SLOT_CAP_SEGMENTS
236
+ points.push(
237
+ PcbScene3dDrillCutoutBuilder.#rotatePoint(
238
+ x,
239
+ y,
240
+ -halfStraight + Math.cos(angle) * radius,
241
+ Math.sin(angle) * radius,
242
+ rotation
243
+ )
244
+ )
245
+ }
246
+
247
+ return points
248
+ }
249
+
250
+ /**
251
+ * Rotates one local point around a drill center.
252
+ * @param {number} centerX
253
+ * @param {number} centerY
254
+ * @param {number} localX
255
+ * @param {number} localY
256
+ * @param {number} rotation
257
+ * @returns {{ x: number, y: number }}
258
+ */
259
+ static #rotatePoint(centerX, centerY, localX, localY, rotation) {
260
+ const cos = Math.cos(rotation)
261
+ const sin = Math.sin(rotation)
262
+
263
+ return {
264
+ x: centerX + localX * cos - localY * sin,
265
+ y: centerY + localX * sin + localY * cos
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Returns true when a drill contour should cut one fill.
271
+ * @param {{ x: number, y: number, bounds: { minX: number, minY: number, maxX: number, maxY: number }, points: { x: number, y: number }[] }} cutout
272
+ * @param {{ points?: { x: number, y: number }[], x1?: number, y1?: number, x2?: number, y2?: number }} fill
273
+ * @returns {boolean}
274
+ */
275
+ static #cutoutTouchesFill(cutout, fill) {
276
+ const fillBounds = PcbScene3dDrillCutoutBuilder.#resolveFillBounds(fill)
277
+
278
+ if (
279
+ !fillBounds ||
280
+ !PcbScene3dDrillCutoutBuilder.#boundsOverlap(
281
+ cutout.bounds,
282
+ fillBounds
283
+ )
284
+ ) {
285
+ return false
286
+ }
287
+
288
+ const fillPoints = PcbScene3dDrillCutoutBuilder.#resolveFillPoints(fill)
289
+ if (fillPoints.length < 3) {
290
+ return true
291
+ }
292
+
293
+ return (
294
+ PcbScene3dDrillCutoutBuilder.#isPointInPolygon(
295
+ cutout,
296
+ fillPoints
297
+ ) ||
298
+ cutout.points.some((point) =>
299
+ PcbScene3dDrillCutoutBuilder.#isPointInPolygon(
300
+ point,
301
+ fillPoints
302
+ )
303
+ )
304
+ )
305
+ }
306
+
307
+ /**
308
+ * Resolves existing authored holes from a fill.
309
+ * @param {{ holes?: { x: number, y: number }[][] }} fill
310
+ * @returns {{ x: number, y: number }[][]}
311
+ */
312
+ static #resolveExistingHoles(fill) {
313
+ return Array.isArray(fill?.holes)
314
+ ? fill.holes.filter((hole) => Array.isArray(hole))
315
+ : []
316
+ }
317
+
318
+ /**
319
+ * Resolves finite polygon points from a fill.
320
+ * @param {{ points?: { x: number, y: number }[] }} fill
321
+ * @returns {{ x: number, y: number }[]}
322
+ */
323
+ static #resolveFillPoints(fill) {
324
+ return (Array.isArray(fill?.points) ? fill.points : [])
325
+ .map((point) => ({
326
+ x: Number(point?.x),
327
+ y: Number(point?.y)
328
+ }))
329
+ .filter(
330
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
331
+ )
332
+ }
333
+
334
+ /**
335
+ * Resolves one fill's bounds.
336
+ * @param {{ points?: { x: number, y: number }[], x1?: number, y1?: number, x2?: number, y2?: number }} fill
337
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
338
+ */
339
+ static #resolveFillBounds(fill) {
340
+ const points = PcbScene3dDrillCutoutBuilder.#resolveFillPoints(fill)
341
+
342
+ if (points.length) {
343
+ return PcbScene3dDrillCutoutBuilder.#resolvePointBounds(points)
344
+ }
345
+
346
+ const x1 = Number(fill?.x1)
347
+ const y1 = Number(fill?.y1)
348
+ const x2 = Number(fill?.x2)
349
+ const y2 = Number(fill?.y2)
350
+
351
+ if (
352
+ !Number.isFinite(x1) ||
353
+ !Number.isFinite(y1) ||
354
+ !Number.isFinite(x2) ||
355
+ !Number.isFinite(y2)
356
+ ) {
357
+ return null
358
+ }
359
+
360
+ return {
361
+ minX: Math.min(x1, x2),
362
+ minY: Math.min(y1, y2),
363
+ maxX: Math.max(x1, x2),
364
+ maxY: Math.max(y1, y2)
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Resolves bounds for a point list.
370
+ * @param {{ x: number, y: number }[]} points
371
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
372
+ */
373
+ static #resolvePointBounds(points) {
374
+ return points.reduce(
375
+ (bounds, point) => ({
376
+ minX: Math.min(bounds.minX, point.x),
377
+ minY: Math.min(bounds.minY, point.y),
378
+ maxX: Math.max(bounds.maxX, point.x),
379
+ maxY: Math.max(bounds.maxY, point.y)
380
+ }),
381
+ {
382
+ minX: Infinity,
383
+ minY: Infinity,
384
+ maxX: -Infinity,
385
+ maxY: -Infinity
386
+ }
387
+ )
388
+ }
389
+
390
+ /**
391
+ * Returns true when two axis-aligned boxes overlap.
392
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} a
393
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} b
394
+ * @returns {boolean}
395
+ */
396
+ static #boundsOverlap(a, b) {
397
+ return (
398
+ a.minX <= b.maxX &&
399
+ a.maxX >= b.minX &&
400
+ a.minY <= b.maxY &&
401
+ a.maxY >= b.minY
402
+ )
403
+ }
404
+
405
+ /**
406
+ * Returns true when a point lies inside a polygon.
407
+ * @param {{ x: number, y: number }} point
408
+ * @param {{ x: number, y: number }[]} polygon
409
+ * @returns {boolean}
410
+ */
411
+ static #isPointInPolygon(point, polygon) {
412
+ let inside = false
413
+
414
+ for (
415
+ let index = 0, previousIndex = polygon.length - 1;
416
+ index < polygon.length;
417
+ previousIndex = index, index += 1
418
+ ) {
419
+ const current = polygon[index]
420
+ const previous = polygon[previousIndex]
421
+ const intersects =
422
+ current.y > point.y !== previous.y > point.y &&
423
+ point.x <
424
+ ((previous.x - current.x) * (point.y - current.y)) /
425
+ (previous.y - current.y) +
426
+ current.x
427
+
428
+ if (intersects) {
429
+ inside = !inside
430
+ }
431
+ }
432
+
433
+ return inside
434
+ }
435
+
436
+ /**
437
+ * Resolves the first positive numeric field from an object.
438
+ * @param {Record<string, unknown>} source
439
+ * @param {string[]} keys
440
+ * @returns {number}
441
+ */
442
+ static #resolvePositiveNumber(source, keys) {
443
+ for (const key of keys) {
444
+ const value = Number(source?.[key])
445
+
446
+ if (Number.isFinite(value) && value > 0) {
447
+ return value
448
+ }
449
+ }
450
+
451
+ return 0
452
+ }
453
+ }