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.
- package/package.json +1 -1
- package/src/core/altium/AltiumParser.mjs +15 -3
- package/src/core/altium/PcbFontMetricsParser.mjs +33 -6
- package/src/core/altium/PcbModelParser.mjs +82 -0
- package/src/core/altium/PcbTextPrimitiveParser.mjs +48 -3
- package/src/core/altium/SchematicPinDesignatorInferer.mjs +401 -0
- package/src/core/altium/SchematicPinParser.mjs +136 -56
- package/src/core/altium/SchematicStreamExtractor.mjs +93 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +40 -12
- package/src/scene3d.mjs +1 -0
- package/src/ui/PcbScene3dBuilder.mjs +574 -145
- package/src/ui/PcbScene3dDrillCutoutBuilder.mjs +453 -0
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +272 -0
- package/src/ui/PcbScene3dTextBoxLayoutResolver.mjs +95 -0
- package/src/ui/SchematicImageRenderer.mjs +134 -32
- package/src/ui/SchematicJunctionRenderer.mjs +22 -4
- package/src/ui/SchematicNoteRenderer.mjs +73 -9
- package/src/ui/SchematicPinSvgRenderer.mjs +25 -3
- package/src/ui/SchematicPowerPortRenderer.mjs +183 -36
- package/src/ui/SchematicSvgRenderer.mjs +2 -2
|
@@ -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
|
+
}
|