Files
modolith/mods/eye_spy/colors.lua
T

299 lines
9.6 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- colors.lua
-- Pure color math utilities for eye_spy.
-- No minetest API calls: safe to use from any context.
eye_spy.colors = {}
-- ---------------------------------------------------------------------------
-- Clamp
-- ---------------------------------------------------------------------------
--- Clamp v to [minv, maxv].
local function clamp(v, minv, maxv)
return math.max(minv, math.min(maxv, v))
end
-- ---------------------------------------------------------------------------
-- RGB packing / unpacking
-- ---------------------------------------------------------------------------
--- Split a packed 24-bit integer into its R, G, B byte components.
local function split_rgb(color)
local r = math.floor(color / 65536) % 256
local g = math.floor(color / 256) % 256
local b = color % 256
return r, g, b
end
--- Pack R, G, B byte components into a 24-bit integer.
local function join_rgb(r, g, b)
return (r * 65536) + (g * 256) + b
end
-- ---------------------------------------------------------------------------
-- Luminance helpers
-- ---------------------------------------------------------------------------
--- Linearise a single 0–255 channel value for WCAG relative-luminance maths.
local function channel_luminance(channel)
local c = channel / 255
if c <= 0.03928 then
return c / 12.92
end
return ((c + 0.055) / 1.055) ^ 2.4
end
--- WCAG 2.1 relative luminance of a packed 24-bit RGB integer.
local function color_luminance(color)
local r, g, b = split_rgb(color)
return (0.2126 * channel_luminance(r))
+ (0.7152 * channel_luminance(g))
+ (0.0722 * channel_luminance(b))
end
--- WCAG 2.1 contrast ratio between two packed 24-bit colours.
local function contrast_ratio(color_a, color_b)
local a_l = color_luminance(color_a)
local b_l = color_luminance(color_b)
return (math.max(a_l, b_l) + 0.05) / (math.min(a_l, b_l) + 0.05)
end
-- ---------------------------------------------------------------------------
-- Colour arithmetic
-- ---------------------------------------------------------------------------
--- Multiply every channel of a packed colour by factor and re-pack.
local function scale_color(color, factor)
local r, g, b = split_rgb(color)
r = clamp(math.floor(r * factor), 0, 255)
g = clamp(math.floor(g * factor), 0, 255)
b = clamp(math.floor(b * factor), 0, 255)
return join_rgb(r, g, b)
end
--- Linear interpolation between two packed colours.
-- t = 0 → color_a, t = 1 → color_b.
local function blend_color(color_a, color_b, t)
local factor = clamp(t or 0.5, 0, 1)
local ar, ag, ab = split_rgb(color_a)
local br, bg, bb = split_rgb(color_b)
return join_rgb(
math.floor((ar * (1 - factor)) + (br * factor) + 0.5),
math.floor((ag * (1 - factor)) + (bg * factor) + 0.5),
math.floor((ab * (1 - factor)) + (bb * factor) + 0.5)
)
end
-- ---------------------------------------------------------------------------
-- HSL conversion
-- ---------------------------------------------------------------------------
--- Convert R, G, B bytes to HSL (hue 0–360, saturation 0–1, lightness 0–1).
local function rgb_to_hsl(r, g, b)
local rn, gn, bn = r / 255, g / 255, b / 255
local max_c = math.max(rn, gn, bn)
local min_c = math.min(rn, gn, bn)
local l = (max_c + min_c) * 0.5
if max_c == min_c then
return 0, 0, l
end
local d = max_c - min_c
local s = l > 0.5 and d / (2 - max_c - min_c) or d / (max_c + min_c)
local h
if max_c == rn then
h = ((gn - bn) / d) % 6
elseif max_c == gn then
h = (bn - rn) / d + 2
else
h = (rn - gn) / d + 4
end
return (h / 6) * 360, s, l
end
-- Hoisted helper for hsl_to_rgb — avoids allocating a new closure per call.
-- Converts a hue position p/q at offset t (all normalised 0–1) to a channel value.
local function hue2rgb(p, q, t)
t = t % 1.0
if t < 1 / 6 then return p + (q - p) * 6 * t end
if t < 1 / 2 then return q end
if t < 2 / 3 then return p + (q - p) * (2 / 3 - t) * 6 end
return p
end
--- Convert HSL (hue 0–360, saturation 0–1, lightness 0–1) to R, G, B bytes.
local function hsl_to_rgb(h, s, l)
if s <= 0 then
local v = clamp(math.floor(l * 255 + 0.5), 0, 255)
return v, v, v
end
h = h / 360
local q = l < 0.5 and l * (1 + s) or l + s - l * s
local p = 2 * l - q
return clamp(math.floor(hue2rgb(p, q, h + 1 / 3) * 255 + 0.5), 0, 255),
clamp(math.floor(hue2rgb(p, q, h) * 255 + 0.5), 0, 255),
clamp(math.floor(hue2rgb(p, q, h - 1 / 3) * 255 + 0.5), 0, 255)
end
-- ---------------------------------------------------------------------------
-- Background luminance (BT.601 perceived brightness, not WCAG)
-- ---------------------------------------------------------------------------
--- Perceived brightness (BT.601) from R, G, B bytes (0–255 range).
-- Returns a value in [0, 255].
local function get_bg_luminance_rgb(r, g, b)
return (r * 0.299) + (g * 0.587) + (b * 0.114)
end
--- Perceived brightness (BT.601) from a 6-character lowercase hex string
-- such as "1a1a1b". Returns a value in [0, 255].
local function get_bg_luminance(rgb_hex)
local r = tonumber(rgb_hex:sub(1, 2), 16) or 26
local g = tonumber(rgb_hex:sub(3, 4), 16) or 26
local b = tonumber(rgb_hex:sub(5, 6), 16) or 27
return get_bg_luminance_rgb(r, g, b)
end
-- ---------------------------------------------------------------------------
-- Foreground selection helpers
-- ---------------------------------------------------------------------------
--- Return whichever of near-black or near-white has the higher contrast ratio
-- against bg_color (packed 24-bit integer).
local function best_monochrome_for_bg(bg_color)
local dark = 0x111111
local light = 0xF0F0F0
if contrast_ratio(light, bg_color) >= contrast_ratio(dark, bg_color) then
return light
end
return dark
end
--- Map a value in [0, 255] to a spectrum colour (red → green → blue → white).
local function spectrum_color(val)
local v = clamp(val or 255, 0, 255)
local r, g, b = 0, 0, 0
if v < 85 then
r = 255 - (v * 3)
g = v * 3
elseif v < 170 then
local x = v - 85
g = 255 - (x * 3)
b = x * 3
elseif v < 230 then
local x = v - 170
r = x * 4
b = 255 - (x * 4)
else
local x = math.floor((v - 230) * (255 / 25))
r, g, b = x, x, x
end
return join_rgb(r, g, b)
end
--- Darken color when the background (given as a hex string) is light,
-- so the colour remains legible.
-- Uses get_bg_luminance_rgb + split_rgb to avoid duplicating the BT.601 formula.
local function adapt_for_bg(color, bg_hex)
if get_bg_luminance(bg_hex) <= 140 then
return color
end
local luminance = get_bg_luminance_rgb(split_rgb(color))
if luminance > 130 then
return scale_color(color, 0.45)
end
return color
end
--- Iteratively blend color toward black or white until it reaches min_ratio
-- contrast against bg_color. Makes up to 5 nudge passes.
local function nudge_contrast(color, bg_color, min_ratio)
local candidate = color
local bg_l = color_luminance(bg_color)
for _ = 1, 5 do
if contrast_ratio(candidate, bg_color) >= (min_ratio or 4.5) then
return candidate
end
local fg_l = color_luminance(candidate)
if fg_l < bg_l then
candidate = blend_color(candidate, 0x000000, 0.32)
else
candidate = blend_color(candidate, 0xFFFFFF, 0.32)
end
end
return candidate
end
--- Adjust color to meet min_ratio contrast against bg_color while
-- preserving hue by blending toward white or black (whichever gives
-- the better contrast) in up to 8 steps.
local function tune_contrast_preserve_hue(color, bg_color, min_ratio)
local required_ratio = min_ratio or 4.0
local candidate = color
if contrast_ratio(candidate, bg_color) >= required_ratio then
return candidate
end
for step = 1, 8 do
if contrast_ratio(candidate, bg_color) >= required_ratio then
return candidate
end
local t = clamp(step * 0.09, 0, 0.70)
local lighter = blend_color(color, 0xFFFFFF, t)
local darker = blend_color(color, 0x000000, t)
if contrast_ratio(lighter, bg_color) >= contrast_ratio(darker, bg_color) then
candidate = lighter
else
candidate = darker
end
end
return candidate
end
-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
eye_spy.colors.clamp = clamp
eye_spy.colors.split_rgb = split_rgb
eye_spy.colors.join_rgb = join_rgb
eye_spy.colors.channel_luminance = channel_luminance
eye_spy.colors.color_luminance = color_luminance
eye_spy.colors.contrast_ratio = contrast_ratio
eye_spy.colors.scale_color = scale_color
eye_spy.colors.blend_color = blend_color
eye_spy.colors.rgb_to_hsl = rgb_to_hsl
eye_spy.colors.hsl_to_rgb = hsl_to_rgb
eye_spy.colors.get_bg_luminance_rgb = get_bg_luminance_rgb
eye_spy.colors.get_bg_luminance = get_bg_luminance
eye_spy.colors.best_monochrome_for_bg = best_monochrome_for_bg
eye_spy.colors.spectrum_color = spectrum_color
eye_spy.colors.adapt_for_bg = adapt_for_bg
eye_spy.colors.nudge_contrast = nudge_contrast
eye_spy.colors.tune_contrast_preserve_hue = tune_contrast_preserve_hue