diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c274719..0c58dba 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,4 @@ github: csqrl -custom: ["https://csqrl.itch.io/colour-utils"] +custom: + - "https://csqrl.itch.io/colour-utils" + - "https://roblox.com/groups/9536808/csqrl#!/store" diff --git a/CHANGELOG.md b/CHANGELOG.md index 04472f8..80fc3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.3.0] + +### Added + +- Implemented colour space conversions. + - `HSL` (`.fromHSL, .toHSL`). + - `LAB` (`.fromLAB, .toLAB`) _(:test_tube: experimental)_. + - `LCH` (`.fromLCH, .toLCH`) _(:test_tube: experimental)_. +- Saturation methods to either saturate or desaturate a colour. +- Tailwind CSS-style palette generator - Generates 10 swatches, given a base colour, and returns a `TailwindPalette` object (see the docs for more details). + +### Changed + +- Updated the docs for Hex and Int. The converter methods were previously documented in PascalCase, but they should have been documented in camelCase. +- The `Palette.Monochromatic` method now accepts an optional second parameter, `swatches`, which defaults to `3`. This is to allow for more control over the number of swatches generated. + - **:warning: Warning:** The behaviour of monochromatic has been changed to allow for more control over the number of swatches generated. + - The new behaviour will return `X` amount of swatches, **including** the base colour. The results do not necessarily include a single lighter and darker swatch, and the resulting array is now sorted from darkest to lightest (most vibrant). + +### + ## [1.2.0] ### Added diff --git a/package.json b/package.json index 796aadb..7142e93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rbxts/colour-utils", - "version": "1.2.0", + "version": "1.3.0", "description": "Colour manipulation utility for Roblox", "homepage": "https://github.com/csqrl/colour-utils", "main": "src/init.lua", diff --git a/src/Desaturate.lua b/src/Desaturate.lua new file mode 100644 index 0000000..7f9d522 --- /dev/null +++ b/src/Desaturate.lua @@ -0,0 +1,19 @@ +local Assert = require(script.Parent._Util.Assert) +local Saturate = require(script.Parent.Saturate) + +local assertTypeOf = Assert.prepTypeOf("Saturate") + +--[=[ + @function Desaturate + @within ColourUtils + + @param colour Color3 -- The colour to desaturate. + @param coefficient number -- The coefficient to desaturate by [0-1]. + @return Color3 -- The desaturated colour. +]=] +return function(colour: Color3, coefficient: number): Color3 + assertTypeOf("colour", "Color3", colour) + assertTypeOf("coefficient", "number", coefficient) + + return Saturate(colour, -coefficient) +end diff --git a/src/Desaturate.spec.lua b/src/Desaturate.spec.lua new file mode 100644 index 0000000..331aae4 --- /dev/null +++ b/src/Desaturate.spec.lua @@ -0,0 +1,35 @@ +local BasicallyIdentical = require(script.Parent._Util.BasicallyIdentical) + +return function() + local Desaturate = require(script.Parent.Desaturate) + + local TEST_BASE = Color3.fromHex("#00a2ff") + local TEST_RESULT = Color3.fromHex("#80d0ff") + local TEST_AMOUNT = 0.5 + + it("should desaturate a colour", function() + local colour = Desaturate(TEST_BASE, TEST_AMOUNT) + + expect(BasicallyIdentical(TEST_RESULT, colour)).to.equal(true) + end) + + it("throws if argument is not a Color3", function() + expect(function() + Desaturate(nil, TEST_AMOUNT) + end).to.throw() + + expect(function() + Desaturate(true, TEST_AMOUNT) + end).to.throw() + end) + + it("throws if amount is not a number", function() + expect(function() + Desaturate(TEST_BASE, nil) + end).to.throw() + + expect(function() + Desaturate(TEST_BASE, true) + end).to.throw() + end) +end diff --git a/src/HSL/init.lua b/src/HSL/init.lua new file mode 100644 index 0000000..248bf18 --- /dev/null +++ b/src/HSL/init.lua @@ -0,0 +1,117 @@ +local Assert = require(script.Parent._Util.Assert) + +local round = math.round +local floor = math.floor +local clamp = math.clamp +local min = math.min +local max = math.max +local abs = math.abs + +--[=[ + @interface HSL + @within HSL + .H number + .S number + .L number +]=] +export type HSL = { + H: number, + S: number, + L: number, +} + +--[=[ + @function toHSL + @within HSL + + @param colour Color3 -- The colour to convert. + @return HSL -- The HSL representation of the colour. +]=] +local function ToHSL(colour: Color3): HSL + Assert.typeOf("ToHSL", "colour", "Color3", colour) + + local channelMin = min(colour.R, colour.G, colour.B) + local channelMax = max(colour.R, colour.G, colour.B) + local delta = channelMax - channelMin + + local hue = 0 + local lightness = (channelMax + channelMin) / 2 + local saturation = if delta == 0 then 0 else delta / (1 - abs(2 * lightness - 1)) + + if delta == 0 then + hue = 0 + elseif channelMax == colour.R then + hue = ((colour.G - colour.B) / delta) % 6 + elseif channelMax == colour.G then + hue = (colour.B - colour.R) / delta + 2 + else + hue = (colour.R - colour.G) / delta + 4 + end + + hue = round(hue * 60) + + if hue < 0 then + hue += 360 + end + + saturation = clamp(abs(round(saturation * 100)), 0, 100) + lightness = clamp(abs(round(lightness * 100)), 0, 100) + + return { + H = hue, + S = saturation, + L = lightness, + } +end + +--[=[ + @function fromHSL + @within HSL + + @param hsl HSL -- The HSL colour to convert. + @return Color3 -- The resulting Color3. +]=] +local function FromHSL(hsl: HSL): Color3 + Assert.typeOf("FromHSL", "hsl", "table", hsl) + + Assert.typeOf("FromHSL", "hsl.H", "number", hsl.H) + Assert.typeOf("FromHSL", "hsl.S", "number", hsl.S) + Assert.typeOf("FromHSL", "hsl.L", "number", hsl.L) + + local saturation = hsl.S / 100 + local lightness = hsl.L / 100 + + local c = (1 - abs(2 * lightness - 1)) * saturation + local x = c * (1 - abs((hsl.H / 60) % 2 - 1)) + local m = lightness - c / 2 + + local red, green, blue = 0, 0, 0 + + if hsl.H >= 0 and hsl.H < 60 then + red, green, blue = c, x, 0 + elseif hsl.H >= 60 and hsl.H < 120 then + red, green, blue = x, c, 0 + elseif hsl.H >= 120 and hsl.H < 180 then + red, green, blue = 0, c, x + elseif hsl.H >= 180 and hsl.H < 240 then + red, green, blue = 0, x, c + elseif hsl.H >= 240 and hsl.H < 300 then + red, green, blue = x, 0, c + elseif hsl.H >= 300 and hsl.H < 360 then + red, green, blue = c, 0, x + end + + red = min(floor((red + m) * 255), 255) + green = min(floor((green + m) * 255), 255) + blue = min(floor((blue + m) * 255), 255) + + return Color3.fromRGB(red, green, blue) +end + +--[=[ + @class HSL +]=] +return { + fromHSL = FromHSL, + toHSL = ToHSL, +} diff --git a/src/HSL/init.spec.lua b/src/HSL/init.spec.lua new file mode 100644 index 0000000..829a4db --- /dev/null +++ b/src/HSL/init.spec.lua @@ -0,0 +1,42 @@ +local BasicallyIdentical = require(script.Parent.Parent._Util.BasicallyIdentical) + +return function() + local HSL = require(script.Parent) + + local TEST_COLOR3 = Color3.fromHex("#00a2ff") + local TEST_HSL = { H = 202, S = 100, L = 50 } + + describe("toHSL", function() + it("throws if argument is not a Color3", function() + expect(function() + HSL.toHSL(nil) + end).to.throw() + end) + + it("should convert to HSL", function() + local hsl = HSL.toHSL(TEST_COLOR3) + + expect(hsl.H).to.equal(TEST_HSL.H) + expect(hsl.S).to.equal(TEST_HSL.S) + expect(hsl.L).to.equal(TEST_HSL.L) + end) + end) + + describe("fromHSL", function() + it("throws if argument is not a HSL table", function() + expect(function() + HSL.fromHSL(nil) + end).to.throw() + + expect(function() + HSL.fromHSL({}) + end).to.throw() + end) + + it("should convert to Color3", function() + local color3 = HSL.fromHSL(TEST_HSL) + + expect(BasicallyIdentical(color3, TEST_COLOR3)).to.equal(true) + end) + end) +end diff --git a/src/Hex/init.lua b/src/Hex/init.lua index 2ef84c8..5f98d6b 100644 --- a/src/Hex/init.lua +++ b/src/Hex/init.lua @@ -11,7 +11,7 @@ local HEX_EXCLUDE_PATTERN = "[^A-Fa-f0-9]" local HEX_FORMAT_PATTERN = "%.2x%.2x%.2x" --[=[ - @function FromHex + @function fromHex @within Hex :::tip @@ -51,7 +51,7 @@ local function FromHex(hex: string): Color3 end --[=[ - @function ToHex + @function toHex @within Hex :::note diff --git a/src/Int/init.lua b/src/Int/init.lua index 182ab0e..767d0b0 100644 --- a/src/Int/init.lua +++ b/src/Int/init.lua @@ -7,7 +7,7 @@ local lshift = bit32.lshift local band = bit32.band --[=[ - @function FromInt + @function fromInt @within Int @param int number -- The integer to convert. @@ -25,7 +25,7 @@ local function FromInt(int: number): Color3 end --[=[ - @function ToInt + @function toInt @within Int @param colour Color3 -- The colour to convert. diff --git a/src/LAB/Constants.lua b/src/LAB/Constants.lua new file mode 100644 index 0000000..b0a8ddd --- /dev/null +++ b/src/LAB/Constants.lua @@ -0,0 +1,25 @@ +--[=[ + @interface LAB + @within LAB + .L number + .A number + .B number +]=] +export type LAB = { + L: number, + A: number, + B: number, +} + +return { + Kn = 18, + + Xn = 0.950470, + Yn = 1, + Zn = 1.088830, + + t0 = 0.137931034, + t1 = 0.206896552, + t2 = 0.12841855, + t3 = 0.008856452, +} diff --git a/src/LAB/FromLAB.lua b/src/LAB/FromLAB.lua new file mode 100644 index 0000000..485b826 --- /dev/null +++ b/src/LAB/FromLAB.lua @@ -0,0 +1,43 @@ +local Assert = require(script.Parent.Parent._Util.Assert) +local isNaN = require(script.Parent.Parent._Util.isNaN) + +local CONST = require(script.Parent.Constants) + +local function XYZ_RGB(value: number): number + return 255 * (if value <= 0.00304 then 12.92 * value else 1.055 * value ^ (1 / 2.4) - 0.055) +end + +local function LAB_XYZ(value: number): number + return if value > CONST.t1 then value ^ 3 else CONST.t2 * (value - CONST.t0) +end + +--[=[ + @function fromLAB + @within LAB + + @param lab LAB -- The colour to convert. + @return Color3 -- The converted colour. +]=] +local function FromLAB(lab: CONST.LAB): Color3 + Assert.typeOf("FromLAB", "lab", "table", lab) + + Assert.typeOf("FromLAB", "lab.L", "number", lab.L) + Assert.typeOf("FromLAB", "lab.A", "number", lab.A) + Assert.typeOf("FromLAB", "lab.B", "number", lab.B) + + local y = (lab.L + 16) / 116 + local x = if isNaN(lab.A) then y else y + lab.A / 500 + local z = if isNaN(lab.B) then y else y - lab.B / 200 + + y = CONST.Yn * LAB_XYZ(y) + x = CONST.Xn * LAB_XYZ(x) + z = CONST.Zn * LAB_XYZ(z) + + local red = XYZ_RGB(3.2404542 * x - 1.5371385 * y - 0.4985314 * z) + local green = XYZ_RGB(-0.9692660 * x + 1.8760108 * y + 0.0415560 * z) + local blue = XYZ_RGB(0.0556434 * x - 0.2040259 * y + 1.0572252 * z) + + return Color3.fromRGB(red, green, blue) +end + +return FromLAB diff --git a/src/LAB/FromLAB.spec.lua b/src/LAB/FromLAB.spec.lua new file mode 100644 index 0000000..91c9fdd --- /dev/null +++ b/src/LAB/FromLAB.spec.lua @@ -0,0 +1,24 @@ +local BasicallyIdentical = require(script.Parent.Parent._Util.BasicallyIdentical) + +return function() + local FromLab = require(script.Parent.FromLAB) + + local TEST_COLOR3 = Color3.fromHex("#00a2ff") + local TEST_LAB = { L = 64.21, A = -1.67, B = -55.7 } + + it("converts a LAB colour to a Color3", function() + local color3 = FromLab(TEST_LAB) + + expect(BasicallyIdentical(color3, TEST_COLOR3)).to.equal(true) + end) + + it("throws if argument is not a LAB table", function() + expect(function() + FromLab(nil) + end).to.throw() + + expect(function() + FromLab({}) + end).to.throw() + end) +end diff --git a/src/LAB/ToLAB.lua b/src/LAB/ToLAB.lua new file mode 100644 index 0000000..565e692 --- /dev/null +++ b/src/LAB/ToLAB.lua @@ -0,0 +1,52 @@ +local Assert = require(script.Parent.Parent._Util.Assert) +local CONST = require(script.Parent.Constants) + +local function RGB_XYZ(value: number): number + if value <= 0.04045 then + return value / 12.92 + end + + return ((value + 0.055) / 1.055) ^ 2.4 +end + +local function XYZ_LAB(value: number): number + if value > CONST.t3 then + return value ^ (1 / 3) + end + + return value / CONST.t2 + CONST.t0 +end + +local function ToVector3(colour: Color3): Vector3 + local red = RGB_XYZ(colour.R) + local green = RGB_XYZ(colour.G) + local blue = RGB_XYZ(colour.B) + + return Vector3.new( + XYZ_LAB((0.4124564 * red + 0.3575761 * green + 0.1804375 * blue) / CONST.Xn), + XYZ_LAB((0.2126729 * red + 0.7151522 * green + 0.0721750 * blue) / CONST.Yn), + XYZ_LAB((0.0193339 * red + 0.1191920 * green + 0.9503041 * blue) / CONST.Zn) + ) +end + +--[=[ + @function toLAB + @within LAB + + @param colour Color3 -- The colour to convert. + @return LAB -- The converted colour. +]=] +local function ToLAB(colour: Color3): CONST.LAB + Assert.typeOf("ToLAB", "colour", "Color3", colour) + + local vector = ToVector3(colour) + local l = 116 * vector.Y - 16 + + return { + L = if l < 0 then 0 else l, + A = 500 * (vector.X - vector.Y), + B = 200 * (vector.Y - vector.Z), + } +end + +return ToLAB diff --git a/src/LAB/ToLAB.spec.lua b/src/LAB/ToLAB.spec.lua new file mode 100644 index 0000000..c33fd62 --- /dev/null +++ b/src/LAB/ToLAB.spec.lua @@ -0,0 +1,20 @@ +return function() + local ToLAB = require(script.Parent.ToLAB) + + local TEST_COLOR3 = Color3.fromHex("#00a2ff") + local TEST_LAB = { L = 64.21, A = -1.67, B = -55.7 } + + it("converts a Color3 to a LAB colour", function() + local lab = ToLAB(TEST_COLOR3) + + expect(lab.L).to.be.near(TEST_LAB.L, 0.02) + expect(lab.A).to.be.near(TEST_LAB.A, 0.02) + expect(lab.B).to.be.near(TEST_LAB.B, 0.02) + end) + + it("throws if argument is not a Color3", function() + expect(function() + ToLAB(nil) + end).to.throw() + end) +end diff --git a/src/LAB/init.lua b/src/LAB/init.lua new file mode 100644 index 0000000..d3f80dc --- /dev/null +++ b/src/LAB/init.lua @@ -0,0 +1,11 @@ +local CONST = require(script.Constants) + +export type LAB = CONST.LAB + +--[=[ + @class LAB +]=] +return { + fromLAB = require(script.FromLAB), + toLAB = require(script.ToLAB), +} diff --git a/src/LCH/Constants.lua b/src/LCH/Constants.lua new file mode 100644 index 0000000..fd83555 --- /dev/null +++ b/src/LCH/Constants.lua @@ -0,0 +1,11 @@ +export type LCH = { + L: number, + C: number, + H: number, +} + +return { + DEG_RAD = math.rad(1), + RAD_DEG = math.deg(1), + NaN = 0 / 0, +} diff --git a/src/LCH/FromLCH.lua b/src/LCH/FromLCH.lua new file mode 100644 index 0000000..cbe65fd --- /dev/null +++ b/src/LCH/FromLCH.lua @@ -0,0 +1,42 @@ +local Assert = require(script.Parent.Parent._Util.Assert) +local isNaN = require(script.Parent.Parent._Util.isNaN) + +local LAB = require(script.Parent.Parent.LAB) +local CONST = require(script.Parent.Constants) + +local sin = math.sin +local cos = math.cos + +local function LCHtoLAB(lch: CONST.LCH): LAB.LAB + if isNaN(lch.H) then + lch.H = 0 + end + + lch.H *= CONST.DEG_RAD + + return { + L = lch.L, + A = cos(lch.H) * lch.C, + B = sin(lch.H) * lch.C, + } +end + +--[=[ + @function fromLCH + @within LCH + + @param lch LCH -- The color to convert. + @return Color3 -- The converted color. +]=] +local function FromLCH(lch: CONST.LCH): Color3 + Assert.typeOf("FromLCH", "lch", "table", lch) + + Assert.typeOf("FromLCH", "lch.L", "number", lch.L) + Assert.typeOf("FromLCH", "lch.C", "number", lch.C) + Assert.typeOf("FromLCH", "lch.H", "number", lch.H) + + local lab = LCHtoLAB(lch) + return LAB.fromLAB(lab) +end + +return FromLCH diff --git a/src/LCH/FromLCH.spec.lua b/src/LCH/FromLCH.spec.lua new file mode 100644 index 0000000..f37f9c5 --- /dev/null +++ b/src/LCH/FromLCH.spec.lua @@ -0,0 +1,24 @@ +local BasicallyIdentical = require(script.Parent.Parent._Util.BasicallyIdentical) + +return function() + local FromLCH = require(script.Parent.FromLCH) + + local TEST_COLOR3 = Color3.fromHex("#00a2ff") + local TEST_LCH = { L = 64.2, C = 55.72, H = 268.27 } + + it("converts a LCH colour to a Color3", function() + local color3 = FromLCH(TEST_LCH) + + expect(BasicallyIdentical(TEST_COLOR3, color3)).to.equal(true) + end) + + it("throws if argument is not a LCH table", function() + expect(function() + FromLCH(nil) + end).to.throw() + + expect(function() + FromLCH({}) + end).to.throw() + end) +end diff --git a/src/LCH/ToLCH.lua b/src/LCH/ToLCH.lua new file mode 100644 index 0000000..60bec21 --- /dev/null +++ b/src/LCH/ToLCH.lua @@ -0,0 +1,39 @@ +local Assert = require(script.Parent.Parent._Util.Assert) +local LAB = require(script.Parent.Parent.LAB) + +local CONST = require(script.Parent.Constants) + +local round = math.round +local atan2 = math.atan2 +local sqrt = math.sqrt + +local function LABtoLCH(lab: LAB.LAB): CONST.LCH + local c = sqrt(lab.A ^ 2 + lab.B ^ 2) + local h = (atan2(lab.B, lab.A) * CONST.RAD_DEG + 360) % 360 + + if round(c * 10000) == 0 then + h = CONST.NaN + end + + return { + L = lab.L, + C = c, + H = h, + } +end + +--[=[ + @function toLCH + @within LCH + + @param colour Color3 -- The color to convert. + @return LCH -- The converted color. +]=] +local function ToLCH(colour: Color3): CONST.LCH + Assert.typeOf("ToLCH", "colour", "Color3", colour) + + local lab = LAB.toLAB(colour) + return LABtoLCH(lab) +end + +return ToLCH diff --git a/src/LCH/ToLCH.spec.lua b/src/LCH/ToLCH.spec.lua new file mode 100644 index 0000000..b0f6a75 --- /dev/null +++ b/src/LCH/ToLCH.spec.lua @@ -0,0 +1,20 @@ +return function() + local ToLCH = require(script.Parent.ToLCH) + + local TEST_COLOR3 = Color3.fromHex("#00a2ff") + local TEST_LCH = { L = 64.2, C = 55.72, H = 268.27 } + + it("converts a Color3 to a LCH colour", function() + local lch = ToLCH(TEST_COLOR3) + + expect(lch.L).to.be.near(TEST_LCH.L, 0.02) + expect(lch.C).to.be.near(TEST_LCH.C, 0.02) + expect(lch.H).to.be.near(TEST_LCH.H, 0.02) + end) + + it("throws if argument is not a Color3", function() + expect(function() + ToLCH(nil) + end).to.throw() + end) +end diff --git a/src/LCH/init.lua b/src/LCH/init.lua new file mode 100644 index 0000000..c05f8e4 --- /dev/null +++ b/src/LCH/init.lua @@ -0,0 +1,11 @@ +local CONST = require(script.Constants) + +export type LCH = CONST.LCH + +--[=[ + @class LCH +]=] +return { + toLCH = require(script.ToLCH), + fromLCH = require(script.FromLCH), +} diff --git a/src/Palette/Monochromatic.lua b/src/Palette/Monochromatic.lua index bebb61e..191925b 100644 --- a/src/Palette/Monochromatic.lua +++ b/src/Palette/Monochromatic.lua @@ -1,24 +1,42 @@ -local Types = require(script.Parent.Parent._Util.Types) local Assert = require(script.Parent.Parent._Util.Assert) -local Lighten = require(script.Parent.Parent.Lighten) -local Darken = require(script.Parent.Parent.Darken) local assertTypeOf = Assert.prepTypeOf("Monochromatic") +local assertEvalArg = Assert.prepEvalArg("Monochromatic") -type Array = Types.Array +local push = table.insert +local floor = math.floor +local sort = table.sort --[=[ @function Monochromatic @within Palette @param base Color3 -- The base colour. + @param swatches number -- The number of swatches to generate. @return {Color3} -- The monochromatic colours. ]=] -return function(base: Color3): Array +return function(base: Color3, swatches: number?): { Color3 } + swatches = swatches or 3 + assertTypeOf("base", "Color3", base) + assertTypeOf("swatches", "number", swatches) + + swatches = floor(swatches) + assertEvalArg("swatches", "be greater than 0", swatches > 0, swatches) + + local h, s, v = base:ToHSV() + local increment = 1 / swatches + + local colours = {} + + for _ = 1, swatches do + push(colours, Color3.fromHSV(h, s, v)) + v = (v + increment) % 1 + end + + sort(colours, function(a, b) + return select(3, a:ToHSV()) < select(3, b:ToHSV()) + end) - return { - Lighten(base, 0.5), - Darken(base, 0.5), - } + return colours end diff --git a/src/Palette/Monochromatic.spec.lua b/src/Palette/Monochromatic.spec.lua index f470d94..9d8e37d 100644 --- a/src/Palette/Monochromatic.spec.lua +++ b/src/Palette/Monochromatic.spec.lua @@ -5,8 +5,8 @@ return function() local swatch = Color3.new(1, 0, 0) local results = { - Color3.new(1, 0.5, 0.5), - Color3.new(0.5, 0, 0), + Color3.new(0.333, 0, 0), + Color3.new(0.666, 0, 0), } it("throws if argument is not a Color3", function() @@ -27,4 +27,15 @@ return function() expect(BasicallyIdentical(results[1], result[1])).to.equal(true) expect(BasicallyIdentical(results[2], result[2])).to.equal(true) end) + + it("throws if argument is not a number", function() + expect(function() + Monochromatic(Color3.new(), true) + end).to.throw() + end) + + it("can generate any number of colors", function() + expect(#Monochromatic(swatch, 2)).to.equal(2) + expect(#Monochromatic(swatch, 30)).to.equal(30) + end) end diff --git a/src/Palette/Tailwind.lua b/src/Palette/Tailwind.lua new file mode 100644 index 0000000..8c640ef --- /dev/null +++ b/src/Palette/Tailwind.lua @@ -0,0 +1,73 @@ +local Types = require(script.Parent.Parent._Util.Types) +local Assert = require(script.Parent.Parent._Util.Assert) +local Array = require(script.Parent.Parent._Util.Array) + +local HSL = require(script.Parent.Parent.HSL) +local Saturate = require(script.Parent.Parent.Saturate) + +local assertTypeOf = Assert.prepTypeOf("Tailwind") + +local tfind = table.find +local abs = math.abs + +local LIGHTNESS_MAP = { 0.95, 0.85, 0.75, 0.65, 0.55, 0.45, 0.35, 0.25, 0.15, 0.05 } +local SATURATION_MAP = { 0.32, 0.16, 0.08, 0.04, 0, 0, 0.04, 0.08, 0.16, 0.32 } + +--[=[ + @interface TailwindPalette + @within Palette + .50 Color3 + .100 Color3 + .200 Color3 + .300 Color3 + .400 Color3 + .500 Color3 + .600 Color3 + .700 Color3 + .800 Color3 + .900 Color3 +]=] +export type TailwindPalette = Types.Dictionary + +local function GetBaseColourSaturationIndex(hsl: HSL.HSL): number + local goal = hsl.L / 100 + + local closestLightness = Array.reduce(LIGHTNESS_MAP, function(previous, current) + return if abs(current - goal) < abs(previous - goal) then current else previous + end) + + return tfind(LIGHTNESS_MAP, closestLightness) +end + +--[=[ + @function Tailwind + @within Palette + + @param base Color3 -- The base colour. + @return TailwindPalette -- The generated palette. +]=] +local function Tailwind(base: Color3): TailwindPalette + assertTypeOf("base", "Color3", base) + + local hsl = HSL.toHSL(base) + local satIndex = GetBaseColourSaturationIndex(hsl) + + local colours = Array.map( + Array.map(LIGHTNESS_MAP, function(lightness: number) + return HSL.fromHSL({ H = hsl.H, S = hsl.S, L = lightness * 100 }) + end), + function(colour: Color3, index) + local satDelta = SATURATION_MAP[index] - SATURATION_MAP[satIndex] + return Saturate(colour, satDelta) + end + ) + + return Array.reduce(colours, function(accumulator, colour: Color3, index) + local key = if index == 1 then 50 else (index - 1) * 100 + accumulator[key] = colour + + return accumulator + end, {}) +end + +return Tailwind diff --git a/src/Palette/Tailwind.spec.lua b/src/Palette/Tailwind.spec.lua new file mode 100644 index 0000000..92dd69c --- /dev/null +++ b/src/Palette/Tailwind.spec.lua @@ -0,0 +1,37 @@ +local BasicallyIdentical = require(script.Parent.Parent._Util.BasicallyIdentical) + +return function() + local Tailwind = require(script.Parent.Tailwind) + + local BASE = Color3.fromHex("#00a2ff") + local RESULT = { + [050] = Color3.fromHex("#dcf1ff"), + [100] = Color3.fromHex("#a5ddff"), + [200] = Color3.fromHex("#74ccff"), + [300] = Color3.fromHex("#44baff"), + [400] = Color3.fromHex("#18aaff"), + [500] = Color3.fromHex("#0091e5"), + [600] = Color3.fromHex("#0071b2"), + [700] = Color3.fromHex("#00507f"), + [800] = Color3.fromHex("#00304c"), + [900] = Color3.fromHex("#000f19"), + } + + it("generates a tailwind-like palette", function() + local palette = Tailwind(BASE) + + for key, colour in pairs(RESULT) do + expect(BasicallyIdentical(colour, palette[key])).to.equal(true) + end + end) + + it("throws if argument is not a Color3", function() + expect(function() + Tailwind(nil) + end).to.throw() + + expect(function() + Tailwind(true) + end).to.throw() + end) +end diff --git a/src/Palette/init.lua b/src/Palette/init.lua index eab10b3..35daee7 100644 --- a/src/Palette/init.lua +++ b/src/Palette/init.lua @@ -6,6 +6,7 @@ return { Complementary = require(script.Complementary), Monochromatic = require(script.Monochromatic), SplitComplementary = require(script.SplitComplementary), + Tailwind = require(script.Tailwind), Tetradic = require(script.Tetradic), Triadic = require(script.Triadic), Vibrant = require(script.Vibrant), diff --git a/src/Saturate.lua b/src/Saturate.lua new file mode 100644 index 0000000..9c0e96b --- /dev/null +++ b/src/Saturate.lua @@ -0,0 +1,24 @@ +local Assert = require(script.Parent._Util.Assert) +local ClampColour = require(script.Parent._Util.ClampColour) + +local assertTypeOf = Assert.prepTypeOf("Saturate") + +local clamp = math.clamp + +--[=[ + @function Saturate + @within ColourUtils + + @param colour Color3 -- The colour to saturate. + @param coefficient number -- The coefficient to saturate by [0-1]. + @return Color3 -- The saturated colour. +]=] +return function(colour: Color3, coefficient: number): Color3 + assertTypeOf("colour", "Color3", colour) + assertTypeOf("coefficient", "number", coefficient) + + local H, S, V = colour:ToHSV() + S += S * coefficient + + return ClampColour(Color3.fromHSV(H, clamp(S, 0, 1), V)) +end diff --git a/src/Saturate.spec.lua b/src/Saturate.spec.lua new file mode 100644 index 0000000..4f358ef --- /dev/null +++ b/src/Saturate.spec.lua @@ -0,0 +1,35 @@ +local BasicallyIdentical = require(script.Parent._Util.BasicallyIdentical) + +return function() + local Saturate = require(script.Parent.Saturate) + + local TEST_BASE = Color3.fromHex("#00a2ff") + local TEST_RESULT = Color3.fromHex("#00a2ff") + local TEST_AMOUNT = 0.5 + + it("should saturate a colour", function() + local colour = Saturate(TEST_BASE, TEST_AMOUNT) + + expect(BasicallyIdentical(TEST_RESULT, colour)).to.equal(true) + end) + + it("throws if argument is not a Color3", function() + expect(function() + Saturate(nil, TEST_AMOUNT) + end).to.throw() + + expect(function() + Saturate(true, TEST_AMOUNT) + end).to.throw() + end) + + it("throws if amount is not a number", function() + expect(function() + Saturate(TEST_BASE, nil) + end).to.throw() + + expect(function() + Saturate(TEST_BASE, true) + end).to.throw() + end) +end diff --git a/src/_Util/Array.lua b/src/_Util/Array.lua new file mode 100644 index 0000000..f705707 --- /dev/null +++ b/src/_Util/Array.lua @@ -0,0 +1,38 @@ +local push = table.insert + +local function map(array: { T }, mapper: (T?, number?) -> T?): { T } + local mapped = {} + + for index, value in ipairs(array) do + local mappedValue = mapper(value, index) + + if mappedValue ~= nil then + push(mapped, mappedValue) + end + end + + return mapped +end + +local function reduce( + array: { T }, + reducer: (({ T } | T)?, T?, number?) -> { T } | T, + initialValue: ({ T } | T)? +): { T } | T + local result, start = initialValue, 1 + + if not result then + result, start = array[1], 2 + end + + for index = start, #array do + result = reducer(result, array[index], index) + end + + return result +end + +return { + map = map, + reduce = reduce, +} diff --git a/src/_Util/Assert.lua b/src/_Util/Assert.lua index 660c77b..936e6e6 100644 --- a/src/_Util/Assert.lua +++ b/src/_Util/Assert.lua @@ -7,16 +7,27 @@ type Array = { [number]: any } Assert.TYPE = { INVALID_TYPE = "%s(...): The `%s` argument must be a %s, but you passed %q (%s)", INVALID_ARRAY = "%s(...): The `%s` argument must be an array of %s, but you passed %q (%s) at index #%d", + INVALID_EVAL_ARG = "%s(...): The `%s` argument must %s, but you passed %q (%s)", } -function Assert.typeOf(methodName: string, argName: string, class: string, value: any?): nil +function Assert.typeOf(methodName: string, argName: string, class: string, value: T?): T local errorText = fmt(Assert.TYPE.INVALID_TYPE, methodName, argName, class, tostring(value), typeof(value)) if typeof(value) ~= class then error(errorText, 3) end - return nil + return value +end + +function Assert.evalArg(methodName: string, argName: string, message: string, eval: boolean?, value: T?): T + local errorText = fmt(Assert.TYPE.INVALID_EVAL_ARG, methodName, argName, message, tostring(value), typeof(value)) + + if eval ~= true then + error(errorText, 3) + end + + return value end function Assert.arrayOf(methodName: string, argName: string, class: string, array: Array): nil @@ -46,6 +57,12 @@ function Assert.prepTypeOf(methodName: string) end end +function Assert.prepEvalArg(methodName: string) + return function(argName: string, message: string, eval: boolean?, value: any?) + return Assert.evalArg(methodName, argName, message, eval, value) + end +end + function Assert.prepArrayOf(methodName: string) return function(argName: string, class: string, array: Array) return Assert.arrayOf(methodName, argName, class, array) diff --git a/src/_Util/init.lua b/src/_Util/init.lua index 83d463c..a9adb4d 100644 --- a/src/_Util/init.lua +++ b/src/_Util/init.lua @@ -1,7 +1,9 @@ return { + Array = require(script.Array), Assert = require(script.Assert), BasicallyIdentical = require(script.BasicallyIdentical), ClampColour = require(script.ClampColour), + isNaN = require(script.isNaN), Schema = require(script.Schema), Types = require(script.Types), } diff --git a/src/_Util/isNaN.lua b/src/_Util/isNaN.lua new file mode 100644 index 0000000..8c04575 --- /dev/null +++ b/src/_Util/isNaN.lua @@ -0,0 +1,3 @@ +return function(value: number): boolean + return value ~= value +end diff --git a/src/init.lua b/src/init.lua index 155eb7a..0003257 100644 --- a/src/init.lua +++ b/src/init.lua @@ -4,6 +4,7 @@ local module = { -- Methods -- Darken = require(script.Darken), + Desaturate = require(script.Desaturate), Emphasise = require(script.Emphasise), GetContrastingColour = require(script.GetContrastingColour), GetContrastRatio = require(script.GetContrastRatio), @@ -14,13 +15,17 @@ local module = { isLight = require(script.isLight), Lighten = require(script.Lighten), Rotate = require(script.Rotate), + Saturate = require(script.Saturate), -- Submodules -- APCA = require(script.APCA), Blend = require(script.Blend), Blind = require(script.Blind), Hex = require(script.Hex), + HSL = require(script.HSL), Int = require(script.Int), + LAB = require(script.LAB), + LCH = require(script.LCH), Palette = require(script.Palette), WCAG = require(script.WCAG), } diff --git a/typings/index.d.ts b/typings/index.d.ts index c0ae5be..f832792 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -7,6 +7,37 @@ declare namespace ColourUtils { TargetValue: number } + type HSL = { + H: number + S: number + L: number + } + + type LAB = { + L: number + A: number + B: number + } + + type LCH = { + L: number + C: number + H: number + } + + type TailwindPalette = { + [50]: Color3 + [100]: Color3 + [200]: Color3 + [300]: Color3 + [400]: Color3 + [500]: Color3 + [600]: Color3 + [700]: Color3 + [800]: Color3 + [900]: Color3 + } + /** * Darkens a colour * @param {Color3} colour - The Color3 to darken @@ -115,6 +146,54 @@ declare namespace ColourUtils { function toInt(colour: Color3): number } + namespace HSL { + /** + * Converts a Color3 into a HSL table + * @param {Color3} colour - A Color3 to convert into a HSL table + * @returns {HSL} + */ + function toHSL(colour: Color3): HSL + + /** + * Converts a HSL table into a Color3 + * @param {HSL} hsl - A HSL table to convert into a Color3 + * @returns {Color3} + */ + function fromHSL(hsl: HSL): Color3 + } + + namespace LAB { + /** + * Converts a Color3 into a LAB table + * @param {Color3} colour - A Color3 to convert into a LAB table + * @returns {LAB} + */ + function toLAB(colour: Color3): LAB + + /** + * Converts a LAB table into a Color3 + * @param {LAB} lab - A LAB table to convert into a Color3 + * @returns {Color3} + */ + function fromLAB(lab: LAB): Color3 + } + + namespace LCH { + /** + * Converts a Color3 into a LCH table + * @param {Color3} + * @returns {LCH} + */ + function toLCH(colour: Color3): LCH + + /** + * Converts a LCH table into a Color3 + * @param {LCH} + * @returns {Color3} + */ + function fromLCH(lch: LCH): Color3 + } + namespace APCA { /** * Calculates the contrast ratio between two colours. The result should be a number between roughly -100 and 100. See {@link https://www.myndex.com/APCA/#general-guidelines-on-levels Myndex's General Guidelines} for more information. @@ -228,9 +307,10 @@ declare namespace ColourUtils { /** * * @param {Color3} base - The Color3 to generate palette from + * @param {number} swatches - The number of swatches to generate * @returns {Color3[]} An array of Color3 values */ - function Monochromatic(base: Color3): Color3[] + function Monochromatic(base: Color3, swatches?: number): Color3[] /** * @@ -260,6 +340,14 @@ declare namespace ColourUtils { * @returns {Color3} A Color3 matching the most "vibrant" colour */ function Vibrant(swatches: Color3[], options?: VibrantOptions): Color3 + + /** + * Generates a monochromatic palette from a base colour, similar to the + * palettes found in Tailwind CSS + * @param {Color3} base - The Color3 to generate palette from + * @returns {TailwindPalette} An object containing Color3 values + */ + function Tailwind(base: Color3): TailwindPalette } namespace WCAG { diff --git a/wally.toml b/wally.toml index 450028b..ef0e5db 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "csqrl/colour-utils" description = "Colour manipulation utility for Roblox" -version = "1.2.0" +version = "1.3.0" license = "MIT" authors = ["csqrl (https://github.com/csqrl)"] registry = "https://github.com/UpliftGames/wally-index"