@js-draw/math 1.0.2 → 1.2.1

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.
@@ -46,6 +46,25 @@ export default class Color4 {
46
46
  * ```
47
47
  */
48
48
  mix(other: Color4, fractionTo: number): Color4;
49
+ /**
50
+ * Ignoring this color's alpha component, returns a vector with components,
51
+ * $$
52
+ * \begin{pmatrix} \colorbox{#F44}{\tt r} \\ \colorbox{#4F4}{\tt g} \\ \colorbox{#44F}{\tt b} \end{pmatrix}
53
+ * $$
54
+ */
55
+ get rgb(): Vec3;
56
+ /**
57
+ * Returns the [relative luminance](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef)
58
+ * of this color in the sRGB color space.
59
+ *
60
+ * Ignores the alpha component.
61
+ */
62
+ relativeLuminance(): number;
63
+ /**
64
+ * Returns the [contrast ratio](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef)
65
+ * between `colorA` and `colorB`.
66
+ */
67
+ static contrastRatio(colorA: Color4, colorB: Color4): number;
49
68
  /**
50
69
  * @returns the component-wise average of `colors`, or `Color4.transparent` if `colors` is empty.
51
70
  */
@@ -57,6 +76,27 @@ export default class Color4 {
57
76
  * The resultant hue is represented in radians and is thus in $[0, 2\pi]$.
58
77
  */
59
78
  asHSV(): Vec3;
79
+ /**
80
+ * Creates a new `Color4` from a representation [in $HSV$](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
81
+ *
82
+ * [Algorithm](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
83
+ *
84
+ * Note that hue must be given **in radians**. While non-standard, this is consistent with
85
+ * {@link asHSV}.
86
+ *
87
+ * `hue` and `value` should range from 0 to 1.
88
+ *
89
+ * @param hue $H \in [0, 2\pi]$
90
+ * @param saturation $S_V \in [0, 1]$
91
+ * @param value $V \in [0, 1]$
92
+ */
93
+ static fromHSV(hue: number, saturation: number, value: number): Color4;
94
+ /**
95
+ * Equivalent to `ofRGB(rgb.x, rgb.y, rgb.z)`.
96
+ *
97
+ * All components should be in the range `[0, 1]` (0 to 1 inclusive).
98
+ */
99
+ static fromRGBVector(rgb: Vec3, alpha?: number): Color4;
60
100
  private hexString;
61
101
  /**
62
102
  * @returns a hexadecimal color string representation of `this`, in the form `#rrggbbaa`.
@@ -85,6 +85,9 @@ class Color4 {
85
85
  if (text === 'none' || text === 'transparent') {
86
86
  return Color4.transparent;
87
87
  }
88
+ if (text === '') {
89
+ return Color4.black;
90
+ }
88
91
  // rgba?: Match both rgb and rgba strings.
89
92
  // ([,0-9.]+): Match any string of only numeric, '.' and ',' characters.
90
93
  const rgbRegex = /^rgba?\(([,0-9.]+)\)$/i;
@@ -144,6 +147,49 @@ class Color4 {
144
147
  const fractionOfThis = 1 - fractionTo;
145
148
  return new Color4(this.r * fractionOfThis + other.r * fractionTo, this.g * fractionOfThis + other.g * fractionTo, this.b * fractionOfThis + other.b * fractionTo, this.a * fractionOfThis + other.a * fractionTo);
146
149
  }
150
+ /**
151
+ * Ignoring this color's alpha component, returns a vector with components,
152
+ * $$
153
+ * \begin{pmatrix} \colorbox{#F44}{\tt r} \\ \colorbox{#4F4}{\tt g} \\ \colorbox{#44F}{\tt b} \end{pmatrix}
154
+ * $$
155
+ */
156
+ get rgb() {
157
+ return Vec3_1.default.of(this.r, this.g, this.b);
158
+ }
159
+ /**
160
+ * Returns the [relative luminance](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef)
161
+ * of this color in the sRGB color space.
162
+ *
163
+ * Ignores the alpha component.
164
+ */
165
+ relativeLuminance() {
166
+ // References:
167
+ // - https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
168
+ // - https://stackoverflow.com/a/9733420
169
+ // Normalize the components, as per above
170
+ const components = [this.r, this.g, this.b].map(component => {
171
+ if (component < 0.03928) {
172
+ return component / 12.92;
173
+ }
174
+ else {
175
+ return Math.pow((component + 0.055) / 1.055, 2.4);
176
+ }
177
+ });
178
+ // From w3.org,
179
+ // > For the sRGB colorspace, the relative luminance of a color is
180
+ // > defined as L = 0.2126 * R + 0.7152 * G + 0.0722 * B
181
+ // where R, G, B are defined in components above.
182
+ return 0.2126 * components[0] + 0.7152 * components[1] + 0.0722 * components[2];
183
+ }
184
+ /**
185
+ * Returns the [contrast ratio](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef)
186
+ * between `colorA` and `colorB`.
187
+ */
188
+ static contrastRatio(colorA, colorB) {
189
+ const L1 = colorA.relativeLuminance();
190
+ const L2 = colorB.relativeLuminance();
191
+ return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
192
+ }
147
193
  /**
148
194
  * @returns the component-wise average of `colors`, or `Color4.transparent` if `colors` is empty.
149
195
  */
@@ -229,6 +275,65 @@ class Color4 {
229
275
  const saturation = value > 0 ? chroma / value : 0;
230
276
  return Vec3_1.default.of(hue, saturation, value);
231
277
  }
278
+ /**
279
+ * Creates a new `Color4` from a representation [in $HSV$](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
280
+ *
281
+ * [Algorithm](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
282
+ *
283
+ * Note that hue must be given **in radians**. While non-standard, this is consistent with
284
+ * {@link asHSV}.
285
+ *
286
+ * `hue` and `value` should range from 0 to 1.
287
+ *
288
+ * @param hue $H \in [0, 2\pi]$
289
+ * @param saturation $S_V \in [0, 1]$
290
+ * @param value $V \in [0, 1]$
291
+ */
292
+ static fromHSV(hue, saturation, value) {
293
+ if (hue < 0) {
294
+ hue += Math.PI * 2;
295
+ }
296
+ hue %= Math.PI * 2;
297
+ // Clamp value and saturation to [0, 1]
298
+ value = Math.max(0, Math.min(1, value));
299
+ saturation = Math.max(0, Math.min(1, saturation));
300
+ // Formula from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB
301
+ // Saturation can be thought of as scaled chroma. Unapply the scaling.
302
+ // See https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation
303
+ const chroma = value * saturation;
304
+ // Determines which edge of the projected color cube
305
+ const huePrime = hue / (Math.PI / 3);
306
+ const secondLargestComponent = chroma * (1 - Math.abs((huePrime % 2) - 1));
307
+ let rgb;
308
+ if (huePrime < 1) {
309
+ rgb = [chroma, secondLargestComponent, 0];
310
+ }
311
+ else if (huePrime < 2) {
312
+ rgb = [secondLargestComponent, chroma, 0];
313
+ }
314
+ else if (huePrime < 3) {
315
+ rgb = [0, chroma, secondLargestComponent];
316
+ }
317
+ else if (huePrime < 4) {
318
+ rgb = [0, secondLargestComponent, chroma];
319
+ }
320
+ else if (huePrime < 5) {
321
+ rgb = [secondLargestComponent, 0, chroma];
322
+ }
323
+ else {
324
+ rgb = [chroma, 0, secondLargestComponent];
325
+ }
326
+ const adjustment = value - chroma;
327
+ return Color4.ofRGB(rgb[0] + adjustment, rgb[1] + adjustment, rgb[2] + adjustment);
328
+ }
329
+ /**
330
+ * Equivalent to `ofRGB(rgb.x, rgb.y, rgb.z)`.
331
+ *
332
+ * All components should be in the range `[0, 1]` (0 to 1 inclusive).
333
+ */
334
+ static fromRGBVector(rgb, alpha) {
335
+ return Color4.ofRGBA(rgb.x, rgb.y, rgb.z, alpha ?? 1);
336
+ }
232
337
  /**
233
338
  * @returns a hexadecimal color string representation of `this`, in the form `#rrggbbaa`.
234
339
  *
@@ -46,6 +46,25 @@ export default class Color4 {
46
46
  * ```
47
47
  */
48
48
  mix(other: Color4, fractionTo: number): Color4;
49
+ /**
50
+ * Ignoring this color's alpha component, returns a vector with components,
51
+ * $$
52
+ * \begin{pmatrix} \colorbox{#F44}{\tt r} \\ \colorbox{#4F4}{\tt g} \\ \colorbox{#44F}{\tt b} \end{pmatrix}
53
+ * $$
54
+ */
55
+ get rgb(): Vec3;
56
+ /**
57
+ * Returns the [relative luminance](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef)
58
+ * of this color in the sRGB color space.
59
+ *
60
+ * Ignores the alpha component.
61
+ */
62
+ relativeLuminance(): number;
63
+ /**
64
+ * Returns the [contrast ratio](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef)
65
+ * between `colorA` and `colorB`.
66
+ */
67
+ static contrastRatio(colorA: Color4, colorB: Color4): number;
49
68
  /**
50
69
  * @returns the component-wise average of `colors`, or `Color4.transparent` if `colors` is empty.
51
70
  */
@@ -57,6 +76,27 @@ export default class Color4 {
57
76
  * The resultant hue is represented in radians and is thus in $[0, 2\pi]$.
58
77
  */
59
78
  asHSV(): Vec3;
79
+ /**
80
+ * Creates a new `Color4` from a representation [in $HSV$](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
81
+ *
82
+ * [Algorithm](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
83
+ *
84
+ * Note that hue must be given **in radians**. While non-standard, this is consistent with
85
+ * {@link asHSV}.
86
+ *
87
+ * `hue` and `value` should range from 0 to 1.
88
+ *
89
+ * @param hue $H \in [0, 2\pi]$
90
+ * @param saturation $S_V \in [0, 1]$
91
+ * @param value $V \in [0, 1]$
92
+ */
93
+ static fromHSV(hue: number, saturation: number, value: number): Color4;
94
+ /**
95
+ * Equivalent to `ofRGB(rgb.x, rgb.y, rgb.z)`.
96
+ *
97
+ * All components should be in the range `[0, 1]` (0 to 1 inclusive).
98
+ */
99
+ static fromRGBVector(rgb: Vec3, alpha?: number): Color4;
60
100
  private hexString;
61
101
  /**
62
102
  * @returns a hexadecimal color string representation of `this`, in the form `#rrggbbaa`.
@@ -79,6 +79,9 @@ class Color4 {
79
79
  if (text === 'none' || text === 'transparent') {
80
80
  return Color4.transparent;
81
81
  }
82
+ if (text === '') {
83
+ return Color4.black;
84
+ }
82
85
  // rgba?: Match both rgb and rgba strings.
83
86
  // ([,0-9.]+): Match any string of only numeric, '.' and ',' characters.
84
87
  const rgbRegex = /^rgba?\(([,0-9.]+)\)$/i;
@@ -138,6 +141,49 @@ class Color4 {
138
141
  const fractionOfThis = 1 - fractionTo;
139
142
  return new Color4(this.r * fractionOfThis + other.r * fractionTo, this.g * fractionOfThis + other.g * fractionTo, this.b * fractionOfThis + other.b * fractionTo, this.a * fractionOfThis + other.a * fractionTo);
140
143
  }
144
+ /**
145
+ * Ignoring this color's alpha component, returns a vector with components,
146
+ * $$
147
+ * \begin{pmatrix} \colorbox{#F44}{\tt r} \\ \colorbox{#4F4}{\tt g} \\ \colorbox{#44F}{\tt b} \end{pmatrix}
148
+ * $$
149
+ */
150
+ get rgb() {
151
+ return Vec3.of(this.r, this.g, this.b);
152
+ }
153
+ /**
154
+ * Returns the [relative luminance](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef)
155
+ * of this color in the sRGB color space.
156
+ *
157
+ * Ignores the alpha component.
158
+ */
159
+ relativeLuminance() {
160
+ // References:
161
+ // - https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
162
+ // - https://stackoverflow.com/a/9733420
163
+ // Normalize the components, as per above
164
+ const components = [this.r, this.g, this.b].map(component => {
165
+ if (component < 0.03928) {
166
+ return component / 12.92;
167
+ }
168
+ else {
169
+ return Math.pow((component + 0.055) / 1.055, 2.4);
170
+ }
171
+ });
172
+ // From w3.org,
173
+ // > For the sRGB colorspace, the relative luminance of a color is
174
+ // > defined as L = 0.2126 * R + 0.7152 * G + 0.0722 * B
175
+ // where R, G, B are defined in components above.
176
+ return 0.2126 * components[0] + 0.7152 * components[1] + 0.0722 * components[2];
177
+ }
178
+ /**
179
+ * Returns the [contrast ratio](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef)
180
+ * between `colorA` and `colorB`.
181
+ */
182
+ static contrastRatio(colorA, colorB) {
183
+ const L1 = colorA.relativeLuminance();
184
+ const L2 = colorB.relativeLuminance();
185
+ return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
186
+ }
141
187
  /**
142
188
  * @returns the component-wise average of `colors`, or `Color4.transparent` if `colors` is empty.
143
189
  */
@@ -223,6 +269,65 @@ class Color4 {
223
269
  const saturation = value > 0 ? chroma / value : 0;
224
270
  return Vec3.of(hue, saturation, value);
225
271
  }
272
+ /**
273
+ * Creates a new `Color4` from a representation [in $HSV$](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
274
+ *
275
+ * [Algorithm](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
276
+ *
277
+ * Note that hue must be given **in radians**. While non-standard, this is consistent with
278
+ * {@link asHSV}.
279
+ *
280
+ * `hue` and `value` should range from 0 to 1.
281
+ *
282
+ * @param hue $H \in [0, 2\pi]$
283
+ * @param saturation $S_V \in [0, 1]$
284
+ * @param value $V \in [0, 1]$
285
+ */
286
+ static fromHSV(hue, saturation, value) {
287
+ if (hue < 0) {
288
+ hue += Math.PI * 2;
289
+ }
290
+ hue %= Math.PI * 2;
291
+ // Clamp value and saturation to [0, 1]
292
+ value = Math.max(0, Math.min(1, value));
293
+ saturation = Math.max(0, Math.min(1, saturation));
294
+ // Formula from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB
295
+ // Saturation can be thought of as scaled chroma. Unapply the scaling.
296
+ // See https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation
297
+ const chroma = value * saturation;
298
+ // Determines which edge of the projected color cube
299
+ const huePrime = hue / (Math.PI / 3);
300
+ const secondLargestComponent = chroma * (1 - Math.abs((huePrime % 2) - 1));
301
+ let rgb;
302
+ if (huePrime < 1) {
303
+ rgb = [chroma, secondLargestComponent, 0];
304
+ }
305
+ else if (huePrime < 2) {
306
+ rgb = [secondLargestComponent, chroma, 0];
307
+ }
308
+ else if (huePrime < 3) {
309
+ rgb = [0, chroma, secondLargestComponent];
310
+ }
311
+ else if (huePrime < 4) {
312
+ rgb = [0, secondLargestComponent, chroma];
313
+ }
314
+ else if (huePrime < 5) {
315
+ rgb = [secondLargestComponent, 0, chroma];
316
+ }
317
+ else {
318
+ rgb = [chroma, 0, secondLargestComponent];
319
+ }
320
+ const adjustment = value - chroma;
321
+ return Color4.ofRGB(rgb[0] + adjustment, rgb[1] + adjustment, rgb[2] + adjustment);
322
+ }
323
+ /**
324
+ * Equivalent to `ofRGB(rgb.x, rgb.y, rgb.z)`.
325
+ *
326
+ * All components should be in the range `[0, 1]` (0 to 1 inclusive).
327
+ */
328
+ static fromRGBVector(rgb, alpha) {
329
+ return Color4.ofRGBA(rgb.x, rgb.y, rgb.z, alpha ?? 1);
330
+ }
226
331
  /**
227
332
  * @returns a hexadecimal color string representation of `this`, in the form `#rrggbbaa`.
228
333
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@js-draw/math",
3
- "version": "1.0.2",
3
+ "version": "1.2.1",
4
4
  "description": "A math library for js-draw. ",
5
5
  "types": "./dist/mjs/lib.d.ts",
6
6
  "main": "./dist/cjs/lib.js",
@@ -45,5 +45,5 @@
45
45
  "svg",
46
46
  "math"
47
47
  ],
48
- "gitHead": "f5a92284625b49b2ef6541bdafce1bd926c10441"
48
+ "gitHead": "0cb756d3150c8b33dd9a3217faa7d18229688f34"
49
49
  }
@@ -49,4 +49,46 @@ describe('Color4', () => {
49
49
  expect(Color4.ofRGB(0.5, 0.5, 0.5).asHSV()).objEq(Vec3.of(0, 0, 0.5));
50
50
  expect(Color4.ofRGB(0.5, 0.25, 0.5).asHSV()).objEq(Vec3.of(Math.PI * 5 / 3, 0.5, 0.5), 0.1);
51
51
  });
52
+
53
+ it('fromHSV(color.asHSV) should return the original color', () => {
54
+ const testColors = [
55
+ Color4.red, Color4.green, Color4.blue,
56
+ Color4.white, Color4.black,
57
+ ];
58
+
59
+ const testWithColor = (color: Color4) => {
60
+ expect(Color4.fromHSV(...color.asHSV().asArray())).objEq(color);
61
+ };
62
+
63
+ for (const color of testColors) {
64
+ testWithColor(color);
65
+ }
66
+
67
+ for (let i = 0; i <= 6; i++) {
68
+ testWithColor(Color4.fromHSV(i * Math.PI / 7, 0.5, 0.5));
69
+ testWithColor(Color4.fromHSV(i * Math.PI / 6, 0.5, 0.5));
70
+ }
71
+ });
72
+
73
+ it('.rgb should return a 3-component vector', () => {
74
+ expect(Color4.red.rgb).objEq(Vec3.of(1, 0, 0));
75
+ expect(Color4.green.rgb).objEq(Vec3.of(0, 1, 0));
76
+ expect(Color4.blue.rgb).objEq(Vec3.of(0, 0, 1));
77
+ });
78
+
79
+ it('should return correct contrast ratios', () => {
80
+ // Expected values from https://webaim.org/resources/contrastchecker/
81
+ const testCases: [ Color4, Color4, number ][] = [
82
+ [ Color4.white, Color4.black, 21 ],
83
+ [ Color4.fromHex('#FF0000'), Color4.black, 5.25 ],
84
+ [ Color4.fromHex('#FF0000'), Color4.fromHex('#0000FF'), 2.14 ],
85
+ [ Color4.fromHex('#300000'), Color4.fromHex('#003000'), 1.26 ],
86
+ [ Color4.fromHex('#300000'), Color4.fromHex('#003000'), 1.26 ],
87
+ [ Color4.fromHex('#D60000'), Color4.fromHex('#003000'), 2.71 ],
88
+ ];
89
+
90
+ for (const [ colorA, colorB, expectedContrast ] of testCases) {
91
+ expect(Color4.contrastRatio(colorA, colorB)).toBeCloseTo(expectedContrast, 1);
92
+ }
93
+ });
52
94
  });
package/src/Color4.ts CHANGED
@@ -93,6 +93,10 @@ export default class Color4 {
93
93
  return Color4.transparent;
94
94
  }
95
95
 
96
+ if (text === '') {
97
+ return Color4.black;
98
+ }
99
+
96
100
  // rgba?: Match both rgb and rgba strings.
97
101
  // ([,0-9.]+): Match any string of only numeric, '.' and ',' characters.
98
102
  const rgbRegex = /^rgba?\(([,0-9.]+)\)$/i;
@@ -170,6 +174,54 @@ export default class Color4 {
170
174
  );
171
175
  }
172
176
 
177
+ /**
178
+ * Ignoring this color's alpha component, returns a vector with components,
179
+ * $$
180
+ * \begin{pmatrix} \colorbox{#F44}{\tt r} \\ \colorbox{#4F4}{\tt g} \\ \colorbox{#44F}{\tt b} \end{pmatrix}
181
+ * $$
182
+ */
183
+ public get rgb() {
184
+ return Vec3.of(this.r, this.g, this.b);
185
+ }
186
+
187
+ /**
188
+ * Returns the [relative luminance](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef)
189
+ * of this color in the sRGB color space.
190
+ *
191
+ * Ignores the alpha component.
192
+ */
193
+ public relativeLuminance(): number {
194
+ // References:
195
+ // - https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
196
+ // - https://stackoverflow.com/a/9733420
197
+
198
+ // Normalize the components, as per above
199
+ const components = [ this.r, this.g, this.b ].map(component => {
200
+ if (component < 0.03928) {
201
+ return component / 12.92;
202
+ } else {
203
+ return Math.pow((component + 0.055) / 1.055, 2.4);
204
+ }
205
+ });
206
+
207
+ // From w3.org,
208
+ // > For the sRGB colorspace, the relative luminance of a color is
209
+ // > defined as L = 0.2126 * R + 0.7152 * G + 0.0722 * B
210
+ // where R, G, B are defined in components above.
211
+ return 0.2126 * components[0] + 0.7152 * components[1] + 0.0722 * components[2];
212
+ }
213
+
214
+ /**
215
+ * Returns the [contrast ratio](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef)
216
+ * between `colorA` and `colorB`.
217
+ */
218
+ public static contrastRatio(colorA: Color4, colorB: Color4): number {
219
+ const L1 = colorA.relativeLuminance();
220
+ const L2 = colorB.relativeLuminance();
221
+
222
+ return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
223
+ }
224
+
173
225
  /**
174
226
  * @returns the component-wise average of `colors`, or `Color4.transparent` if `colors` is empty.
175
227
  */
@@ -263,6 +315,70 @@ export default class Color4 {
263
315
  return Vec3.of(hue, saturation, value);
264
316
  }
265
317
 
318
+ /**
319
+ * Creates a new `Color4` from a representation [in $HSV$](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
320
+ *
321
+ * [Algorithm](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
322
+ *
323
+ * Note that hue must be given **in radians**. While non-standard, this is consistent with
324
+ * {@link asHSV}.
325
+ *
326
+ * `hue` and `value` should range from 0 to 1.
327
+ *
328
+ * @param hue $H \in [0, 2\pi]$
329
+ * @param saturation $S_V \in [0, 1]$
330
+ * @param value $V \in [0, 1]$
331
+ */
332
+ public static fromHSV(hue: number, saturation: number, value: number) {
333
+ if (hue < 0) {
334
+ hue += Math.PI * 2;
335
+ }
336
+ hue %= Math.PI * 2;
337
+
338
+ // Clamp value and saturation to [0, 1]
339
+ value = Math.max(0, Math.min(1, value));
340
+ saturation = Math.max(0, Math.min(1, saturation));
341
+
342
+ // Formula from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB
343
+
344
+ // Saturation can be thought of as scaled chroma. Unapply the scaling.
345
+ // See https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation
346
+ const chroma = value * saturation;
347
+
348
+ // Determines which edge of the projected color cube
349
+ const huePrime = hue / (Math.PI / 3);
350
+
351
+ const secondLargestComponent = chroma * (1 - Math.abs((huePrime % 2) - 1));
352
+
353
+ let rgb;
354
+ if (huePrime < 1) {
355
+ rgb = [ chroma, secondLargestComponent, 0 ];
356
+ } else if (huePrime < 2) {
357
+ rgb = [ secondLargestComponent, chroma, 0 ];
358
+ } else if (huePrime < 3) {
359
+ rgb = [ 0, chroma, secondLargestComponent ];
360
+ } else if (huePrime < 4) {
361
+ rgb = [ 0, secondLargestComponent, chroma ];
362
+ } else if (huePrime < 5) {
363
+ rgb = [ secondLargestComponent, 0, chroma ];
364
+ } else {
365
+ rgb = [ chroma, 0, secondLargestComponent ];
366
+ }
367
+
368
+ const adjustment = value - chroma;
369
+ return Color4.ofRGB(rgb[0] + adjustment, rgb[1] + adjustment, rgb[2] + adjustment);
370
+ }
371
+
372
+
373
+ /**
374
+ * Equivalent to `ofRGB(rgb.x, rgb.y, rgb.z)`.
375
+ *
376
+ * All components should be in the range `[0, 1]` (0 to 1 inclusive).
377
+ */
378
+ public static fromRGBVector(rgb: Vec3, alpha?: number) {
379
+ return Color4.ofRGBA(rgb.x, rgb.y, rgb.z, alpha ?? 1);
380
+ }
381
+
266
382
  private hexString: string|null = null;
267
383
 
268
384
  /**