// Visual Arts Spirit - Color Module
// Color spaces, gradients, palettes, and color theory
module visual.color @ 0.1.0
use @univrs/physics.waves.{ Wave, interference }
// ============================================================================
// COLOR SPACES
// ============================================================================
pub gen RGB {
has r: u8
has g: u8
has b: u8
rule valid_range {
this.r <= 255 && this.g <= 255 && this.b <= 255
}
fun to_hex() -> string {
return "#" + hex(this.r) + hex(this.g) + hex(this.b)
}
fun to_normalized() -> (f64, f64, f64) {
return (
this.r as f64 / 255.0,
this.g as f64 / 255.0,
this.b as f64 / 255.0
)
}
fun luminance() -> f64 {
// sRGB luminance formula
let (r, g, b) = this.to_normalized()
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
docs {
RGB color space with 8-bit channels.
Standard additive color model for displays.
}
}
pub gen RGBA {
has r: u8
has g: u8
has b: u8
has a: u8
rule valid_range {
this.r <= 255 && this.g <= 255 && this.b <= 255 && this.a <= 255
}
fun to_rgb() -> RGB {
return RGB { r: this.r, g: this.g, b: this.b }
}
fun opacity() -> f64 {
return this.a as f64 / 255.0
}
fun premultiply() -> RGBA {
let alpha = this.opacity()
return RGBA {
r: (this.r as f64 * alpha) as u8,
g: (this.g as f64 * alpha) as u8,
b: (this.b as f64 * alpha) as u8,
a: this.a
}
}
docs {
RGBA color space with alpha channel.
Alpha 0 = transparent, 255 = opaque.
}
}
pub gen HSL {
has h: f64 // Hue: 0-360 degrees
has s: f64 // Saturation: 0-1
has l: f64 // Lightness: 0-1
rule valid_hue {
this.h >= 0.0 && this.h < 360.0
}
rule valid_saturation {
this.s >= 0.0 && this.s <= 1.0
}
rule valid_lightness {
this.l >= 0.0 && this.l <= 1.0
}
fun rotate(degrees: f64) -> HSL {
let new_h = (this.h + degrees) % 360.0
if new_h < 0.0 {
new_h = new_h + 360.0
}
return HSL { h: new_h, s: this.s, l: this.l }
}
fun saturate(amount: f64) -> HSL {
return HSL {
h: this.h,
s: clamp(this.s + amount, 0.0, 1.0),
l: this.l
}
}
fun lighten(amount: f64) -> HSL {
return HSL {
h: this.h,
s: this.s,
l: clamp(this.l + amount, 0.0, 1.0)
}
}
docs {
HSL color space - Hue, Saturation, Lightness.
More intuitive for color manipulation.
Hue is angular (0-360), S and L are normalized (0-1).
}
}
pub gen HSV {
has h: f64 // Hue: 0-360 degrees
has s: f64 // Saturation: 0-1
has v: f64 // Value: 0-1
rule valid_hue {
this.h >= 0.0 && this.h < 360.0
}
rule valid_saturation {
this.s >= 0.0 && this.s <= 1.0
}
rule valid_value {
this.v >= 0.0 && this.v <= 1.0
}
docs {
HSV color space - Hue, Saturation, Value.
Also known as HSB (Brightness).
Similar to HSL but with Value instead of Lightness.
}
}
pub gen CMYK {
has c: f64 // Cyan: 0-1
has m: f64 // Magenta: 0-1
has y: f64 // Yellow: 0-1
has k: f64 // Key (Black): 0-1
rule valid_channels {
this.c >= 0.0 && this.c <= 1.0 &&
this.m >= 0.0 && this.m <= 1.0 &&
this.y >= 0.0 && this.y <= 1.0 &&
this.k >= 0.0 && this.k <= 1.0
}
docs {
CMYK color space - Cyan, Magenta, Yellow, Key (Black).
Subtractive color model for print.
}
}
pub gen LAB {
has l: f64 // Lightness: 0-100
has a: f64 // Green-Red: -128 to 127
has b: f64 // Blue-Yellow: -128 to 127
rule valid_lightness {
this.l >= 0.0 && this.l <= 100.0
}
rule valid_a_channel {
this.a >= -128.0 && this.a <= 127.0
}
rule valid_b_channel {
this.b >= -128.0 && this.b <= 127.0
}
fun delta_e(other: LAB) -> f64 {
// CIE76 color difference
let dl = this.l - other.l
let da = this.a - other.a
let db = this.b - other.b
return sqrt(dl * dl + da * da + db * db)
}
docs {
CIE LAB color space - perceptually uniform.
L = Lightness (0-100)
a = Green to Red axis (-128 to 127)
b = Blue to Yellow axis (-128 to 127)
Used for accurate color difference calculations.
}
}
// ============================================================================
// GRADIENTS AND PALETTES
// ============================================================================
pub gen GradientStop {
has position: f64 // 0-1
has color: RGB
rule valid_position {
this.position >= 0.0 && this.position <= 1.0
}
docs {
A single stop in a color gradient.
Position 0 = start, 1 = end.
}
}
pub gen ColorGradient {
has stops: Vec<GradientStop>
has interpolation: InterpolationMode
rule minimum_stops {
this.stops.length >= 2
}
rule ordered_stops {
for i in 0..(this.stops.length - 1) {
this.stops[i].position <= this.stops[i + 1].position
}
}
fun sample(t: f64) -> RGB {
let t_clamped = clamp(t, 0.0, 1.0)
// Find surrounding stops
let lower_idx = 0
let upper_idx = 1
for i in 0..(this.stops.length - 1) {
if this.stops[i].position <= t_clamped &&
this.stops[i + 1].position >= t_clamped {
lower_idx = i
upper_idx = i + 1
break
}
}
let lower = this.stops[lower_idx]
let upper = this.stops[upper_idx]
let local_t = (t_clamped - lower.position) /
(upper.position - lower.position)
return lerp_color(lower.color, upper.color, local_t)
}
fun reverse() -> ColorGradient {
let reversed = this.stops.map(|stop| {
GradientStop {
position: 1.0 - stop.position,
color: stop.color
}
}).reverse()
return ColorGradient { stops: reversed, interpolation: this.interpolation }
}
docs {
A color gradient defined by multiple stops.
Supports linear and curved interpolation.
}
}
pub gen InterpolationMode {
type: enum {
Linear,
Smooth, // Hermite smoothstep
Bezier, // Cubic bezier
Step // No interpolation
}
docs {
How colors are interpolated between gradient stops.
}
}
pub gen Palette {
has name: string
has colors: Vec<RGB>
rule non_empty {
this.colors.length > 0
}
fun get(index: u64) -> Option<RGB> {
if index < this.colors.length {
return Some(this.colors[index])
}
return None
}
fun sample(t: f64) -> RGB {
let index = (t * (this.colors.length - 1) as f64) as u64
return this.colors[clamp(index, 0, this.colors.length - 1)]
}
fun to_gradient() -> ColorGradient {
let stops = this.colors.enumerate().map(|(i, color)| {
GradientStop {
position: i as f64 / (this.colors.length - 1) as f64,
color: color
}
}).collect()
return ColorGradient { stops: stops, interpolation: InterpolationMode::Linear }
}
docs {
A named collection of colors.
Can be sampled or converted to a gradient.
}
}
pub gen ColorHarmony {
type: enum {
Complementary, // 180 degrees apart
Analogous, // Adjacent on wheel
Triadic, // 120 degrees apart
SplitComplementary,
Tetradic, // Rectangle on wheel
Square // 90 degrees apart
}
docs {
Color harmony schemes based on color wheel relationships.
}
}
// ============================================================================
// TRAITS
// ============================================================================
pub trait Blendable {
fun blend(other: Self, t: f64) -> Self
docs {
Types that can be smoothly blended between two values.
t = 0 returns self, t = 1 returns other.
}
}
pub trait Complementary {
fun complement() -> Self
fun split_complement() -> (Self, Self)
docs {
Types that have complementary relationships.
For colors, complement is 180 degrees on color wheel.
}
}
pub trait ColorConvertible {
fun to_rgb() -> RGB
fun from_rgb(rgb: RGB) -> Self
docs {
Types that can be converted to/from RGB.
}
}
// ============================================================================
// TRAIT IMPLEMENTATIONS
// ============================================================================
impl Blendable for RGB {
fun blend(other: RGB, t: f64) -> RGB {
return lerp_color(this, other, t)
}
}
impl Complementary for RGB {
fun complement() -> RGB {
let hsl = rgb_to_hsl(this)
let comp_hsl = hsl.rotate(180.0)
return hsl_to_rgb(comp_hsl)
}
fun split_complement() -> (RGB, RGB) {
let hsl = rgb_to_hsl(this)
let left = hsl_to_rgb(hsl.rotate(150.0))
let right = hsl_to_rgb(hsl.rotate(210.0))
return (left, right)
}
}
impl Complementary for HSL {
fun complement() -> HSL {
return this.rotate(180.0)
}
fun split_complement() -> (HSL, HSL) {
return (this.rotate(150.0), this.rotate(210.0))
}
}
impl ColorConvertible for HSL {
fun to_rgb() -> RGB {
return hsl_to_rgb(this)
}
fun from_rgb(rgb: RGB) -> HSL {
return rgb_to_hsl(rgb)
}
}
impl ColorConvertible for HSV {
fun to_rgb() -> RGB {
return hsv_to_rgb(this)
}
fun from_rgb(rgb: RGB) -> HSV {
return rgb_to_hsv(rgb)
}
}
impl ColorConvertible for CMYK {
fun to_rgb() -> RGB {
return cmyk_to_rgb(this)
}
fun from_rgb(rgb: RGB) -> CMYK {
return rgb_to_cmyk(rgb)
}
}
impl ColorConvertible for LAB {
fun to_rgb() -> RGB {
return lab_to_rgb(this)
}
fun from_rgb(rgb: RGB) -> LAB {
return rgb_to_lab(rgb)
}
}
// ============================================================================
// CONVERSION FUNCTIONS
// ============================================================================
pub fun rgb_to_hsl(c: RGB) -> HSL {
let (r, g, b) = c.to_normalized()
let max_c = max(r, max(g, b))
let min_c = min(r, min(g, b))
let delta = max_c - min_c
let l = (max_c + min_c) / 2.0
if delta == 0.0 {
return HSL { h: 0.0, s: 0.0, l: l }
}
let s = if l < 0.5 {
delta / (max_c + min_c)
} else {
delta / (2.0 - max_c - min_c)
}
let h = if max_c == r {
60.0 * (((g - b) / delta) % 6.0)
} else if max_c == g {
60.0 * (((b - r) / delta) + 2.0)
} else {
60.0 * (((r - g) / delta) + 4.0)
}
let h_normalized = if h < 0.0 { h + 360.0 } else { h }
return HSL { h: h_normalized, s: s, l: l }
docs {
Convert RGB to HSL color space.
Algorithm based on standard RGB-HSL conversion.
}
}
pub fun hsl_to_rgb(c: HSL) -> RGB {
if c.s == 0.0 {
let gray = (c.l * 255.0) as u8
return RGB { r: gray, g: gray, b: gray }
}
let q = if c.l < 0.5 {
c.l * (1.0 + c.s)
} else {
c.l + c.s - c.l * c.s
}
let p = 2.0 * c.l - q
let h = c.h / 360.0
fun hue_to_rgb(p: f64, q: f64, t: f64) -> f64 {
let t_adj = if t < 0.0 { t + 1.0 } else if t > 1.0 { t - 1.0 } else { t }
if t_adj < 1.0 / 6.0 {
return p + (q - p) * 6.0 * t_adj
}
if t_adj < 1.0 / 2.0 {
return q
}
if t_adj < 2.0 / 3.0 {
return p + (q - p) * (2.0 / 3.0 - t_adj) * 6.0
}
return p
}
return RGB {
r: (hue_to_rgb(p, q, h + 1.0 / 3.0) * 255.0) as u8,
g: (hue_to_rgb(p, q, h) * 255.0) as u8,
b: (hue_to_rgb(p, q, h - 1.0 / 3.0) * 255.0) as u8
}
docs {
Convert HSL to RGB color space.
}
}
pub fun rgb_to_hsv(c: RGB) -> HSV {
let (r, g, b) = c.to_normalized()
let max_c = max(r, max(g, b))
let min_c = min(r, min(g, b))
let delta = max_c - min_c
let v = max_c
let s = if max_c == 0.0 { 0.0 } else { delta / max_c }
if delta == 0.0 {
return HSV { h: 0.0, s: 0.0, v: v }
}
let h = if max_c == r {
60.0 * (((g - b) / delta) % 6.0)
} else if max_c == g {
60.0 * (((b - r) / delta) + 2.0)
} else {
60.0 * (((r - g) / delta) + 4.0)
}
let h_normalized = if h < 0.0 { h + 360.0 } else { h }
return HSV { h: h_normalized, s: s, v: v }
docs {
Convert RGB to HSV color space.
}
}
pub fun hsv_to_rgb(c: HSV) -> RGB {
if c.s == 0.0 {
let gray = (c.v * 255.0) as u8
return RGB { r: gray, g: gray, b: gray }
}
let h = c.h / 60.0
let i = floor(h) as u64
let f = h - i as f64
let p = c.v * (1.0 - c.s)
let q = c.v * (1.0 - c.s * f)
let t = c.v * (1.0 - c.s * (1.0 - f))
let (r, g, b) = match i % 6 {
0 { (c.v, t, p) }
1 { (q, c.v, p) }
2 { (p, c.v, t) }
3 { (p, q, c.v) }
4 { (t, p, c.v) }
5 { (c.v, p, q) }
}
return RGB {
r: (r * 255.0) as u8,
g: (g * 255.0) as u8,
b: (b * 255.0) as u8
}
docs {
Convert HSV to RGB color space.
}
}
pub fun rgb_to_cmyk(c: RGB) -> CMYK {
let (r, g, b) = c.to_normalized()
let k = 1.0 - max(r, max(g, b))
if k == 1.0 {
return CMYK { c: 0.0, m: 0.0, y: 0.0, k: 1.0 }
}
return CMYK {
c: (1.0 - r - k) / (1.0 - k),
m: (1.0 - g - k) / (1.0 - k),
y: (1.0 - b - k) / (1.0 - k),
k: k
}
docs {
Convert RGB to CMYK color space.
Note: This is a simple conversion; print profiles may vary.
}
}
pub fun cmyk_to_rgb(c: CMYK) -> RGB {
return RGB {
r: ((1.0 - c.c) * (1.0 - c.k) * 255.0) as u8,
g: ((1.0 - c.m) * (1.0 - c.k) * 255.0) as u8,
b: ((1.0 - c.y) * (1.0 - c.k) * 255.0) as u8
}
docs {
Convert CMYK to RGB color space.
}
}
pub fun rgb_to_lab(c: RGB) -> LAB {
// First convert to XYZ
let (r, g, b) = c.to_normalized()
// sRGB to linear RGB
fun linearize(v: f64) -> f64 {
if v > 0.04045 {
return pow((v + 0.055) / 1.055, 2.4)
}
return v / 12.92
}
let r_lin = linearize(r)
let g_lin = linearize(g)
let b_lin = linearize(b)
// RGB to XYZ (D65 illuminant)
let x = r_lin * 0.4124564 + g_lin * 0.3575761 + b_lin * 0.1804375
let y = r_lin * 0.2126729 + g_lin * 0.7151522 + b_lin * 0.0721750
let z = r_lin * 0.0193339 + g_lin * 0.1191920 + b_lin * 0.9503041
// Normalize to D65 white point
let x_n = x / 0.95047
let y_n = y / 1.00000
let z_n = z / 1.08883
// XYZ to LAB
fun f(t: f64) -> f64 {
let delta = 6.0 / 29.0
if t > pow(delta, 3.0) {
return pow(t, 1.0 / 3.0)
}
return t / (3.0 * delta * delta) + 4.0 / 29.0
}
return LAB {
l: 116.0 * f(y_n) - 16.0,
a: 500.0 * (f(x_n) - f(y_n)),
b: 200.0 * (f(y_n) - f(z_n))
}
docs {
Convert RGB to CIE LAB color space via XYZ.
Uses D65 illuminant reference white.
}
}
pub fun lab_to_rgb(c: LAB) -> RGB {
// LAB to XYZ
let y_n = (c.l + 16.0) / 116.0
let x_n = c.a / 500.0 + y_n
let z_n = y_n - c.b / 200.0
fun f_inv(t: f64) -> f64 {
let delta = 6.0 / 29.0
if t > delta {
return pow(t, 3.0)
}
return 3.0 * delta * delta * (t - 4.0 / 29.0)
}
// Denormalize from D65
let x = f_inv(x_n) * 0.95047
let y = f_inv(y_n) * 1.00000
let z = f_inv(z_n) * 1.08883
// XYZ to linear RGB
let r_lin = x * 3.2404542 - y * 1.5371385 - z * 0.4985314
let g_lin = -x * 0.9692660 + y * 1.8760108 + z * 0.0415560
let b_lin = x * 0.0556434 - y * 0.2040259 + z * 1.0572252
// Linear RGB to sRGB
fun gamma(v: f64) -> f64 {
if v > 0.0031308 {
return 1.055 * pow(v, 1.0 / 2.4) - 0.055
}
return 12.92 * v
}
return RGB {
r: (clamp(gamma(r_lin), 0.0, 1.0) * 255.0) as u8,
g: (clamp(gamma(g_lin), 0.0, 1.0) * 255.0) as u8,
b: (clamp(gamma(b_lin), 0.0, 1.0) * 255.0) as u8
}
docs {
Convert CIE LAB to RGB via XYZ.
}
}
// ============================================================================
// BLENDING AND INTERPOLATION
// ============================================================================
pub fun lerp_color(a: RGB, b: RGB, t: f64) -> RGB {
let t_clamped = clamp(t, 0.0, 1.0)
return RGB {
r: ((a.r as f64) * (1.0 - t_clamped) + (b.r as f64) * t_clamped) as u8,
g: ((a.g as f64) * (1.0 - t_clamped) + (b.g as f64) * t_clamped) as u8,
b: ((a.b as f64) * (1.0 - t_clamped) + (b.b as f64) * t_clamped) as u8
}
docs {
Linear interpolation between two RGB colors.
t = 0 returns a, t = 1 returns b.
}
}
pub fun blend_colors(colors: Vec<RGB>, weights: Vec<f64>) -> RGB {
let total_weight = weights.sum()
let r = colors.zip(weights).map(|(c, w)| c.r as f64 * w).sum() / total_weight
let g = colors.zip(weights).map(|(c, w)| c.g as f64 * w).sum() / total_weight
let b = colors.zip(weights).map(|(c, w)| c.b as f64 * w).sum() / total_weight
return RGB {
r: clamp(r, 0.0, 255.0) as u8,
g: clamp(g, 0.0, 255.0) as u8,
b: clamp(b, 0.0, 255.0) as u8
}
docs {
Blend multiple colors with given weights.
}
}
// ============================================================================
// PALETTE GENERATION
// ============================================================================
pub fun harmonious_palette(base: HSL, harmony: ColorHarmony, count: u64) -> Palette {
let colors = match harmony {
ColorHarmony::Complementary {
vec![
hsl_to_rgb(base),
hsl_to_rgb(base.rotate(180.0))
]
}
ColorHarmony::Analogous {
let step = 30.0
(0..count).map(|i| {
let offset = (i as f64 - (count - 1) as f64 / 2.0) * step
hsl_to_rgb(base.rotate(offset))
}).collect()
}
ColorHarmony::Triadic {
vec![
hsl_to_rgb(base),
hsl_to_rgb(base.rotate(120.0)),
hsl_to_rgb(base.rotate(240.0))
]
}
ColorHarmony::SplitComplementary {
vec![
hsl_to_rgb(base),
hsl_to_rgb(base.rotate(150.0)),
hsl_to_rgb(base.rotate(210.0))
]
}
ColorHarmony::Tetradic {
vec![
hsl_to_rgb(base),
hsl_to_rgb(base.rotate(90.0)),
hsl_to_rgb(base.rotate(180.0)),
hsl_to_rgb(base.rotate(270.0))
]
}
ColorHarmony::Square {
vec![
hsl_to_rgb(base),
hsl_to_rgb(base.rotate(90.0)),
hsl_to_rgb(base.rotate(180.0)),
hsl_to_rgb(base.rotate(270.0))
]
}
}
return Palette { name: harmony.to_string(), colors: colors }
docs {
Generate a color palette based on color harmony rules.
}
}
pub fun analogous_colors(base: HSL, count: u64, spread: f64) -> Vec<RGB> {
let step = spread / (count - 1) as f64
let start = -spread / 2.0
return (0..count).map(|i| {
hsl_to_rgb(base.rotate(start + i as f64 * step))
}).collect()
docs {
Generate analogous colors around a base hue.
spread: total degrees to span (e.g., 60 for +-30 degrees)
}
}
pub fun triadic_colors(base: HSL) -> (RGB, RGB, RGB) {
return (
hsl_to_rgb(base),
hsl_to_rgb(base.rotate(120.0)),
hsl_to_rgb(base.rotate(240.0))
)
docs {
Generate three colors equally spaced on the color wheel.
}
}
pub fun tetradic_colors(base: HSL) -> (RGB, RGB, RGB, RGB) {
return (
hsl_to_rgb(base),
hsl_to_rgb(base.rotate(90.0)),
hsl_to_rgb(base.rotate(180.0)),
hsl_to_rgb(base.rotate(270.0))
)
docs {
Generate four colors in a square on the color wheel.
}
}
// ============================================================================
// WAVE-BASED COLOR EFFECTS (using physics.waves)
// ============================================================================
pub fun color_wave_interference(c1: RGB, c2: RGB, phase_shift: f64) -> RGB {
// Model colors as waves and compute interference
let wave1 = Wave {
amplitude: c1.luminance(),
frequency: 1.0,
phase: 0.0
}
let wave2 = Wave {
amplitude: c2.luminance(),
frequency: 1.0,
phase: phase_shift
}
let result_wave = interference(wave1, wave2)
let intensity = result_wave.amplitude
// Blend colors weighted by interference pattern
return lerp_color(c1, c2, clamp(intensity, 0.0, 1.0))
docs {
Create color effects using wave interference patterns.
Uses physics.waves for the underlying wave math.
}
}
docs {
Visual Arts Spirit - Color Module
Comprehensive color space support and color theory tools.
Color Spaces:
- RGB/RGBA: Standard 8-bit additive color
- HSL: Hue-Saturation-Lightness (intuitive manipulation)
- HSV: Hue-Saturation-Value (similar to HSL)
- CMYK: Cyan-Magenta-Yellow-Key (print colors)
- LAB: CIE LAB (perceptually uniform)
Features:
- Full bidirectional conversion between all spaces
- Color gradients with multiple interpolation modes
- Named palettes and palette generation
- Color harmony schemes (complementary, analogous, triadic, etc.)
- Wave-based color effects via physics.waves
Color Theory:
- Complementary: 180 degrees on color wheel
- Analogous: Adjacent colors (30 degree steps)
- Triadic: Three colors at 120 degree intervals
- Tetradic/Square: Four colors at 90 degree intervals
- Split-complementary: Base + two colors adjacent to complement
}