pcb-scene3d-viewer 1.1.49 → 1.2.0

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.
Files changed (63) hide show
  1. package/README.md +46 -21
  2. package/docs/api.md +109 -8
  3. package/docs/circuitjson.md +143 -29
  4. package/docs/model-format.md +50 -7
  5. package/docs/release-notes-v1.2.0.md +122 -0
  6. package/docs/testing.md +30 -1
  7. package/package.json +6 -3
  8. package/spec/library-scope.md +10 -2
  9. package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
  10. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
  12. package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
  13. package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
  14. package/src/PcbModelArchiveExporter.mjs +28 -112
  15. package/src/PcbModelArchiveSourceBundle.mjs +326 -0
  16. package/src/PcbScene3dAabbIndex.mjs +464 -0
  17. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
  18. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
  19. package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
  20. package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
  21. package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
  22. package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
  23. package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
  24. package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
  25. package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
  26. package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
  27. package/src/PcbScene3dController.mjs +40 -34
  28. package/src/PcbScene3dCopperFactory.mjs +36 -22
  29. package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
  30. package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
  31. package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
  32. package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
  33. package/src/PcbScene3dCopperTextFactory.mjs +12 -4
  34. package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
  35. package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
  36. package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
  37. package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
  38. package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
  39. package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
  40. package/src/PcbScene3dDrillPathFactory.mjs +86 -16
  41. package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
  42. package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
  43. package/src/PcbScene3dExternalModels.mjs +23 -24
  44. package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
  45. package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
  46. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
  47. package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
  48. package/src/PcbScene3dModelContent.mjs +236 -0
  49. package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
  50. package/src/PcbScene3dModelIdentity.mjs +106 -0
  51. package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
  52. package/src/PcbScene3dPreparedPolygon.mjs +709 -0
  53. package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
  54. package/src/PcbScene3dRuntime.mjs +45 -45
  55. package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
  56. package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
  57. package/src/PcbScene3dShellRenderer.mjs +75 -6
  58. package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
  59. package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
  60. package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
  61. package/src/PcbScene3dStepLoader.mjs +11 -10
  62. package/src/PcbScene3dText.mjs +1 -1
  63. package/src/PcbScene3dTriangleVertexQueryBounds.mjs +302 -0
@@ -0,0 +1,464 @@
1
+ /**
2
+ * @typedef {object} PcbScene3dAabbBounds
3
+ * @property {number} minX
4
+ * @property {number} maxX
5
+ * @property {number} minY
6
+ * @property {number} maxY
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} PcbScene3dAabbEntry
11
+ * @property {*} item
12
+ * @property {PcbScene3dAabbBounds} bounds
13
+ * @property {number} sourceIndex
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} PcbScene3dAabbNode
18
+ * @property {PcbScene3dAabbBounds} bounds
19
+ * @property {number} start
20
+ * @property {number} end
21
+ * @property {PcbScene3dAabbNode | null} left
22
+ * @property {PcbScene3dAabbNode | null} right
23
+ */
24
+
25
+ /**
26
+ * Immutable broad-phase index for axis-aligned two-dimensional bounds.
27
+ */
28
+ export class PcbScene3dAabbIndex {
29
+ static #DEFAULT_LEAF_SIZE = 12
30
+
31
+ /** @type {PcbScene3dAabbEntry[]} */
32
+ #entries
33
+
34
+ /** @type {PcbScene3dAabbEntry[]} */
35
+ #overflow
36
+
37
+ /** @type {PcbScene3dAabbNode | null} */
38
+ #root
39
+
40
+ /** @type {number} */
41
+ #leafSize
42
+
43
+ /**
44
+ * Builds an index without mutating the source items or their bounds.
45
+ * @param {Iterable<*>} items
46
+ * @param {{ resolveBounds?: (item: *, index: number) => PcbScene3dAabbBounds, resolveSourceIndex?: (item: *, index: number) => number, leafSize?: number }} [options]
47
+ */
48
+ constructor(items, options = {}) {
49
+ const resolveBounds =
50
+ options.resolveBounds || PcbScene3dAabbIndex.#defaultResolveBounds
51
+ const resolveSourceIndex =
52
+ options.resolveSourceIndex ||
53
+ PcbScene3dAabbIndex.#defaultResolveSourceIndex
54
+ const requestedLeafSize =
55
+ options.leafSize ?? PcbScene3dAabbIndex.#DEFAULT_LEAF_SIZE
56
+
57
+ this.#entries = []
58
+ this.#overflow = []
59
+ this.#leafSize =
60
+ Number.isFinite(requestedLeafSize) && requestedLeafSize > 0
61
+ ? Math.max(1, Math.floor(requestedLeafSize))
62
+ : PcbScene3dAabbIndex.#DEFAULT_LEAF_SIZE
63
+
64
+ const sourceItems = Array.from(items || [])
65
+ for (let index = 0; index < sourceItems.length; index += 1) {
66
+ const item = sourceItems[index]
67
+ const resolvedBounds = resolveBounds(item, index)
68
+ const bounds = {
69
+ minX: resolvedBounds?.minX,
70
+ maxX: resolvedBounds?.maxX,
71
+ minY: resolvedBounds?.minY,
72
+ maxY: resolvedBounds?.maxY
73
+ }
74
+ const entry = {
75
+ item,
76
+ bounds,
77
+ sourceIndex: resolveSourceIndex(item, index)
78
+ }
79
+
80
+ if (PcbScene3dAabbIndex.#isFiniteBounds(bounds)) {
81
+ this.#entries.push(entry)
82
+ } else {
83
+ this.#overflow.push(entry)
84
+ }
85
+ }
86
+
87
+ this.#root = this.#buildTree(0, this.#entries.length)
88
+ }
89
+
90
+ /**
91
+ * Returns items whose bounds overlap the requested bounds.
92
+ * @param {PcbScene3dAabbBounds} bounds
93
+ * @param {{ epsilon?: number, stable?: boolean }} [options]
94
+ * @returns {*[]}
95
+ */
96
+ query(bounds, options = {}) {
97
+ const result = []
98
+ return this.queryInto(bounds, result, options)
99
+ }
100
+
101
+ /**
102
+ * Appends items whose bounds overlap the requested bounds to a target.
103
+ * @param {PcbScene3dAabbBounds} bounds
104
+ * @param {*[]} target
105
+ * @param {{ epsilon?: number, stable?: boolean }} [options]
106
+ * @returns {*[]}
107
+ */
108
+ queryInto(bounds, target, options = {}) {
109
+ const epsilon = options.epsilon ?? 0
110
+ const stable = options.stable === true
111
+ const includeAll = !PcbScene3dAabbIndex.#isFiniteBounds(bounds)
112
+ const matches = stable ? [] : target
113
+
114
+ this.#collectTreeMatches(
115
+ this.#root,
116
+ bounds,
117
+ epsilon,
118
+ includeAll,
119
+ stable,
120
+ matches
121
+ )
122
+ this.#collectOverflowMatches(
123
+ bounds,
124
+ epsilon,
125
+ includeAll,
126
+ stable,
127
+ matches
128
+ )
129
+
130
+ if (stable) {
131
+ matches.sort(PcbScene3dAabbIndex.#compareSourceIndexes)
132
+ for (let index = 0; index < matches.length; index += 1) {
133
+ target.push(matches[index].item)
134
+ }
135
+ }
136
+
137
+ return target
138
+ }
139
+
140
+ /**
141
+ * Builds a median-split tree for an entry range.
142
+ * @param {number} start Inclusive entry offset.
143
+ * @param {number} end Exclusive entry offset.
144
+ * @returns {PcbScene3dAabbNode | null}
145
+ */
146
+ #buildTree(start, end) {
147
+ if (start >= end) {
148
+ return null
149
+ }
150
+
151
+ const bounds = this.#resolveRangeBounds(start, end)
152
+ const node = {
153
+ bounds,
154
+ start,
155
+ end,
156
+ left: null,
157
+ right: null
158
+ }
159
+
160
+ if (end - start <= this.#leafSize) {
161
+ return node
162
+ }
163
+
164
+ const width = bounds.maxX - bounds.minX
165
+ const height = bounds.maxY - bounds.minY
166
+ const axis = width >= height ? 'x' : 'y'
167
+ const medianIndex = start + Math.floor((end - start) / 2)
168
+
169
+ this.#quickselect(start, end - 1, medianIndex, axis)
170
+ node.left = this.#buildTree(start, medianIndex)
171
+ node.right = this.#buildTree(medianIndex, end)
172
+ return node
173
+ }
174
+
175
+ /**
176
+ * Resolves aggregate bounds for a non-empty entry range.
177
+ * @param {number} start Inclusive entry offset.
178
+ * @param {number} end Exclusive entry offset.
179
+ * @returns {PcbScene3dAabbBounds}
180
+ */
181
+ #resolveRangeBounds(start, end) {
182
+ const first = this.#entries[start].bounds
183
+ const bounds = {
184
+ minX: first.minX,
185
+ maxX: first.maxX,
186
+ minY: first.minY,
187
+ maxY: first.maxY
188
+ }
189
+
190
+ for (let index = start + 1; index < end; index += 1) {
191
+ const entryBounds = this.#entries[index].bounds
192
+ bounds.minX = Math.min(bounds.minX, entryBounds.minX)
193
+ bounds.maxX = Math.max(bounds.maxX, entryBounds.maxX)
194
+ bounds.minY = Math.min(bounds.minY, entryBounds.minY)
195
+ bounds.maxY = Math.max(bounds.maxY, entryBounds.maxY)
196
+ }
197
+
198
+ return bounds
199
+ }
200
+
201
+ /**
202
+ * Places the requested entry at its sorted position along one axis.
203
+ * @param {number} left Inclusive lower entry offset.
204
+ * @param {number} right Inclusive upper entry offset.
205
+ * @param {number} targetIndex Desired sorted entry offset.
206
+ * @param {'x' | 'y'} axis Split axis.
207
+ * @returns {void}
208
+ */
209
+ #quickselect(left, right, targetIndex, axis) {
210
+ let lowerBound = left
211
+ let upperBound = right
212
+
213
+ while (lowerBound < upperBound) {
214
+ const pivot =
215
+ this.#entries[
216
+ lowerBound + Math.floor((upperBound - lowerBound) / 2)
217
+ ]
218
+ const partition = this.#partition(
219
+ lowerBound,
220
+ upperBound,
221
+ pivot,
222
+ axis
223
+ )
224
+
225
+ if (targetIndex < partition.lower) {
226
+ upperBound = partition.lower - 1
227
+ } else if (targetIndex > partition.upper) {
228
+ lowerBound = partition.upper + 1
229
+ } else {
230
+ return
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Partitions an entry range around one pivot coordinate in place.
237
+ * @param {number} left Inclusive lower entry offset.
238
+ * @param {number} right Inclusive upper entry offset.
239
+ * @param {PcbScene3dAabbEntry} pivot Pivot entry.
240
+ * @param {'x' | 'y'} axis Split axis.
241
+ * @returns {{ lower: number, upper: number }} Equal partition offsets.
242
+ */
243
+ #partition(left, right, pivot, axis) {
244
+ const pivotCoordinate = PcbScene3dAabbIndex.#entryCoordinate(
245
+ pivot,
246
+ axis
247
+ )
248
+ let lower = left
249
+ let index = left
250
+ let upper = right
251
+
252
+ while (index <= upper) {
253
+ const coordinate = PcbScene3dAabbIndex.#entryCoordinate(
254
+ this.#entries[index],
255
+ axis
256
+ )
257
+
258
+ if (coordinate < pivotCoordinate) {
259
+ this.#swapEntries(lower, index)
260
+ lower += 1
261
+ index += 1
262
+ } else if (coordinate > pivotCoordinate) {
263
+ this.#swapEntries(index, upper)
264
+ upper -= 1
265
+ } else {
266
+ index += 1
267
+ }
268
+ }
269
+
270
+ return { lower, upper }
271
+ }
272
+
273
+ /**
274
+ * Swaps two entries while building the tree.
275
+ * @param {number} firstIndex
276
+ * @param {number} secondIndex
277
+ * @returns {void}
278
+ */
279
+ #swapEntries(firstIndex, secondIndex) {
280
+ if (firstIndex === secondIndex) {
281
+ return
282
+ }
283
+
284
+ const first = this.#entries[firstIndex]
285
+ this.#entries[firstIndex] = this.#entries[secondIndex]
286
+ this.#entries[secondIndex] = first
287
+ }
288
+
289
+ /**
290
+ * Collects matching tree entries or items into a target.
291
+ * @param {PcbScene3dAabbNode | null} node
292
+ * @param {PcbScene3dAabbBounds} queryBounds
293
+ * @param {number} epsilon
294
+ * @param {boolean} includeAll
295
+ * @param {boolean} collectEntries
296
+ * @param {(PcbScene3dAabbEntry | *)[]} target
297
+ * @returns {void}
298
+ */
299
+ #collectTreeMatches(
300
+ node,
301
+ queryBounds,
302
+ epsilon,
303
+ includeAll,
304
+ collectEntries,
305
+ target
306
+ ) {
307
+ if (
308
+ !node ||
309
+ (!includeAll &&
310
+ !PcbScene3dAabbIndex.#overlaps(
311
+ node.bounds,
312
+ queryBounds,
313
+ epsilon
314
+ ))
315
+ ) {
316
+ return
317
+ }
318
+
319
+ if (node.left || node.right) {
320
+ this.#collectTreeMatches(
321
+ node.left,
322
+ queryBounds,
323
+ epsilon,
324
+ includeAll,
325
+ collectEntries,
326
+ target
327
+ )
328
+ this.#collectTreeMatches(
329
+ node.right,
330
+ queryBounds,
331
+ epsilon,
332
+ includeAll,
333
+ collectEntries,
334
+ target
335
+ )
336
+ return
337
+ }
338
+
339
+ for (let index = node.start; index < node.end; index += 1) {
340
+ const entry = this.#entries[index]
341
+ if (
342
+ includeAll ||
343
+ PcbScene3dAabbIndex.#overlaps(
344
+ entry.bounds,
345
+ queryBounds,
346
+ epsilon
347
+ )
348
+ ) {
349
+ target.push(collectEntries ? entry : entry.item)
350
+ }
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Collects matching overflow entries or items into a target.
356
+ * @param {PcbScene3dAabbBounds} queryBounds
357
+ * @param {number} epsilon
358
+ * @param {boolean} includeAll
359
+ * @param {boolean} collectEntries
360
+ * @param {(PcbScene3dAabbEntry | *)[]} target
361
+ * @returns {void}
362
+ */
363
+ #collectOverflowMatches(
364
+ queryBounds,
365
+ epsilon,
366
+ includeAll,
367
+ collectEntries,
368
+ target
369
+ ) {
370
+ for (let index = 0; index < this.#overflow.length; index += 1) {
371
+ const entry = this.#overflow[index]
372
+ if (
373
+ includeAll ||
374
+ PcbScene3dAabbIndex.#overlaps(
375
+ entry.bounds,
376
+ queryBounds,
377
+ epsilon
378
+ )
379
+ ) {
380
+ target.push(collectEntries ? entry : entry.item)
381
+ }
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Resolves an entry midpoint without overflowing finite coordinates.
387
+ * @param {PcbScene3dAabbEntry} entry
388
+ * @param {'x' | 'y'} axis
389
+ * @returns {number}
390
+ */
391
+ static #entryCoordinate(entry, axis) {
392
+ if (axis === 'x') {
393
+ return entry.bounds.minX * 0.5 + entry.bounds.maxX * 0.5
394
+ }
395
+
396
+ return entry.bounds.minY * 0.5 + entry.bounds.maxY * 0.5
397
+ }
398
+
399
+ /**
400
+ * Compares entry source indexes for stable query results.
401
+ * @param {PcbScene3dAabbEntry} first
402
+ * @param {PcbScene3dAabbEntry} second
403
+ * @returns {number}
404
+ */
405
+ static #compareSourceIndexes(first, second) {
406
+ if (first.sourceIndex < second.sourceIndex) {
407
+ return -1
408
+ }
409
+ if (first.sourceIndex > second.sourceIndex) {
410
+ return 1
411
+ }
412
+ return 0
413
+ }
414
+
415
+ /**
416
+ * Returns true when every bound coordinate is finite.
417
+ * @param {PcbScene3dAabbBounds | null | undefined} bounds
418
+ * @returns {boolean}
419
+ */
420
+ static #isFiniteBounds(bounds) {
421
+ return Boolean(
422
+ bounds &&
423
+ Number.isFinite(bounds.minX) &&
424
+ Number.isFinite(bounds.maxX) &&
425
+ Number.isFinite(bounds.minY) &&
426
+ Number.isFinite(bounds.maxY)
427
+ )
428
+ }
429
+
430
+ /**
431
+ * Returns true when two bounds touch or overlap within an epsilon.
432
+ * @param {PcbScene3dAabbBounds} first
433
+ * @param {PcbScene3dAabbBounds} second
434
+ * @param {number} epsilon
435
+ * @returns {boolean}
436
+ */
437
+ static #overlaps(first, second, epsilon) {
438
+ return !(
439
+ first.maxX < second.minX - epsilon ||
440
+ first.minX > second.maxX + epsilon ||
441
+ first.maxY < second.minY - epsilon ||
442
+ first.minY > second.maxY + epsilon
443
+ )
444
+ }
445
+
446
+ /**
447
+ * Resolves bounds from an item's bounds property.
448
+ * @param {{ bounds: PcbScene3dAabbBounds }} item
449
+ * @returns {PcbScene3dAabbBounds}
450
+ */
451
+ static #defaultResolveBounds(item) {
452
+ return item.bounds
453
+ }
454
+
455
+ /**
456
+ * Resolves the source position of an item.
457
+ * @param {*} _item
458
+ * @param {number} index
459
+ * @returns {number}
460
+ */
461
+ static #defaultResolveSourceIndex(_item, index) {
462
+ return index
463
+ }
464
+ }
@@ -420,7 +420,7 @@ export class PcbScene3dBoardAssemblyPresentation {
420
420
  * @returns {number}
421
421
  */
422
422
  static #resolveSurfaceColor(board, options) {
423
- return PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, {
423
+ return PcbScene3dBoardMaterialPalette.resolveBoardSurfaceColor(board, {
424
424
  hasBoardAssemblyModel: true,
425
425
  sourceFormat: options?.sourceFormat
426
426
  })
@@ -98,9 +98,10 @@ export class PcbScene3dBoardEdgeCutoutBuilder {
98
98
  * Returns true when a cutout can safely be added as a shape hole.
99
99
  * @param {{ x: number, y: number }[]} hole
100
100
  * @param {{ x: number, y: number }[]} contour
101
+ * @param {{ isCircular: true, centerX: number, centerY: number, radius: number } | null} [resolvedCircle]
101
102
  * @returns {boolean}
102
103
  */
103
- static isHoleInsideContour(hole, contour) {
104
+ static isHoleInsideContour(hole, contour, resolvedCircle) {
104
105
  if (
105
106
  !Array.isArray(hole) ||
106
107
  !Array.isArray(contour) ||
@@ -110,10 +111,13 @@ export class PcbScene3dBoardEdgeCutoutBuilder {
110
111
  return false
111
112
  }
112
113
 
113
- const circularHole = PcbScene3dCutoutCircleDetector.resolve(
114
- hole,
115
- PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
116
- )
114
+ const circularHole =
115
+ resolvedCircle !== undefined
116
+ ? resolvedCircle
117
+ : PcbScene3dCutoutCircleDetector.resolve(
118
+ hole,
119
+ PcbScene3dBoardEdgeCutoutBuilder.#GEOMETRY_EPSILON
120
+ )
117
121
  if (circularHole) {
118
122
  return PcbScene3dBoardEdgeCutoutBuilder.#isCircularHoleInsideContour(
119
123
  circularHole,
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export class PcbScene3dBoardMaterialPalette {
5
5
  static #DEFAULT_SURFACE_COLOR = 0x2a5f27
6
+ static #BOARD_SURFACE_DARKEN_RATIO = 0.88
6
7
 
7
8
  /**
8
9
  * Resolves the solder-mask face color for the generated board shell.
@@ -25,6 +26,19 @@ export class PcbScene3dBoardMaterialPalette {
25
26
  : PcbScene3dBoardMaterialPalette.#DEFAULT_SURFACE_COLOR
26
27
  }
27
28
 
29
+ /**
30
+ * Resolves the darker display color for visible board solder-mask faces.
31
+ * @param {{ surfaceColor?: number } | undefined} board Board metadata.
32
+ * @param {{ hasBoardAssemblyModel?: boolean, sourceFormat?: string }} [options] Scene options.
33
+ * @returns {number}
34
+ */
35
+ static resolveBoardSurfaceColor(board, options = {}) {
36
+ return PcbScene3dBoardMaterialPalette.#darkenHexColor(
37
+ PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, options),
38
+ PcbScene3dBoardMaterialPalette.#BOARD_SURFACE_DARKEN_RATIO
39
+ )
40
+ }
41
+
28
42
  /**
29
43
  * Returns true when the source format supplies display-stable board colors.
30
44
  * @param {{ sourceFormat?: string }} options Scene options.
@@ -52,4 +66,19 @@ export class PcbScene3dBoardMaterialPalette {
52
66
  static isGeneratedBodyVisible(options = {}) {
53
67
  return true
54
68
  }
69
+
70
+ /**
71
+ * Darkens a packed RGB color while preserving its hue.
72
+ * @param {number} color Packed RGB color.
73
+ * @param {number} ratio Channel multiplier.
74
+ * @returns {number}
75
+ */
76
+ static #darkenHexColor(color, ratio) {
77
+ const multiplier = Math.min(Math.max(Number(ratio || 0), 0), 1)
78
+
79
+ return [16, 8, 0].reduce((output, shift) => {
80
+ const channel = Math.round(((color >> shift) & 255) * multiplier)
81
+ return output | (channel << shift)
82
+ }, 0)
83
+ }
55
84
  }