# Color — Layer 1: Rgb/Hsl types, conversion, and string show for Bend 2.0.2. # # Channel conventions (documented, not enforced by the constructors): # - Rgb.r/g/b: U32 octets in 0..=255 (values outside are clamped by convert # helpers that go through channel_u8 / channel_to_u8). # - Rgb.a / Hsl.a: F32 alpha, conventionally 0.0..=1.0. # - Hsl.h: F32 hue in degrees, conventionally 0.0..=360.0. # - Hsl.s / Hsl.l: F32 saturation and lightness in the unit interval 0.0..=1.0. # # Conversion uses Base F32 arithmetic. Results are approximate: F32 ops are # opaque to definitional equality, and channel_to_u8 rounds with F32.round # then clamps. Primary colors and many modest RGB triples round-trip at # runtime; that is not a definitional claim. # # Layer 1 only: no random_*, no sampling. ColorRange validates min/max without RNG. # Base only; no termination escapes. import Base type Rgb is Data: Rgb{r: U32, g: U32, b: U32, a: F32} type Hsl is Data: Hsl{h: F32, s: F32, l: F32, a: F32} # Inclusive RGB axis-aligned box for later sampling layers. Alpha is part of # each endpoint; validate checks every channel with the natural order. type ColorRange is Data: ColorRange{min: Rgb, max: Rgb} # Constructors # ------------ def Color.rgb(r: U32, g: U32, b: U32, a: F32) -> Rgb: Rgb{r, g, b, a} def Color.hsl(h: F32, s: F32, l: F32, a: F32) -> Hsl: Hsl{h, s, l, a} def Color.range(min: Rgb, max: Rgb) -> ColorRange: ColorRange{min, max} # Clamp helpers # ------------- def Color.clamp_u8(x: U32) -> U32: U32.clamp(x, 0, 255) def Color.clamp_unit(+x: F32) -> F32: F32.clamp(x, 0.0, 1.0) def Color.clamp_hue(+h: F32) -> F32: F32.mod(F32.add(F32.mod(h, 360.0), 360.0), 360.0) def Color.clamp_rgb(c: Rgb) -> Rgb: match c: case Rgb{r, g, b, a}: Rgb{ Color.clamp_u8(r), Color.clamp_u8(g), Color.clamp_u8(b), Color.clamp_unit(a)} def Color.clamp_hsl(c: Hsl) -> Hsl: match c: case Hsl{h, s, l, a}: Hsl{ Color.clamp_hue(h), Color.clamp_unit(s), Color.clamp_unit(l), Color.clamp_unit(a)} # Field projections (for laws and callers that prefer functions) # ------------------------------------------------------------- def Color.rgb_r(c: Rgb) -> U32: match c: case Rgb{r, g, b, a}: r def Color.rgb_g(c: Rgb) -> U32: match c: case Rgb{r, g, b, a}: g def Color.rgb_b(c: Rgb) -> U32: match c: case Rgb{r, g, b, a}: b def Color.rgb_a(c: Rgb) -> F32: match c: case Rgb{r, g, b, a}: a def Color.hsl_h(c: Hsl) -> F32: match c: case Hsl{h, s, l, a}: h def Color.hsl_s(c: Hsl) -> F32: match c: case Hsl{h, s, l, a}: s def Color.hsl_l(c: Hsl) -> F32: match c: case Hsl{h, s, l, a}: l def Color.hsl_a(c: Hsl) -> F32: match c: case Hsl{h, s, l, a}: a # Internal channel mapping # ------------------------ def Color.channel_u8(+x: U32) -> F32: F32.div(U32.to_f32(Color.clamp_u8(x)), 255.0) def Color.channel_to_u8(+x: F32) -> U32: Color.clamp_u8( F32.to_u32(F32.round(F32.mul(Color.clamp_unit(x), 255.0)))) # RGB -> HSL # ---------- def Color.rgb_to_hsl.hue_from_r(+r: F32, +g: F32, +b: F32, +d: F32) -> F32: +base = F32.div(F32.sub(g, b), d) Bool.pick(F32, F32.is_lt(g, b), F32.add(base, 6.0), base) def Color.rgb_to_hsl.hue(+r: F32, +g: F32, +b: F32, +mx: F32, +d: F32) -> F32: Bool.pick(F32, F32.is_eq(mx, r), Color.rgb_to_hsl.hue_from_r(r, g, b, d), Bool.pick(F32, F32.is_eq(mx, g), F32.add(F32.div(F32.sub(b, r), d), 2.0), F32.add(F32.div(F32.sub(r, g), d), 4.0))) def Color.rgb_to_hsl.sat(+mx: F32, +mn: F32, +l: F32, +d: F32) -> F32: Bool.pick(F32, F32.is_gt(l, 0.5), F32.div(d, F32.sub(F32.sub(2.0, mx), mn)), F32.div(d, F32.add(mx, mn))) def Color.rgb_to_hsl.chromatic( +r: F32, +g: F32, +b: F32, +mx: F32, +mn: F32, +l: F32, +a: F32 ) -> Hsl: +d = F32.sub(mx, mn) h6 = Color.rgb_to_hsl.hue(r, g, b, mx, d) h = F32.mul(F32.div(h6, 6.0), 360.0) s = Color.rgb_to_hsl.sat(mx, mn, l, d) Hsl{h, s, l, a} def Color.rgb_to_hsl(c: Rgb) -> Hsl: match c: case Rgb{+r, +g, +b, +a}: +rf = Color.channel_u8(r) +gf = Color.channel_u8(g) +bf = Color.channel_u8(b) +mx = F32.max(rf, F32.max(gf, bf)) +mn = F32.min(rf, F32.min(gf, bf)) +l = F32.mul(F32.add(mx, mn), 0.5) Bool.pick(Hsl, F32.is_eq(mx, mn), Hsl{0.0, 0.0, l, a}, Color.rgb_to_hsl.chromatic(rf, gf, bf, mx, mn, l, a)) # HSL -> RGB # ---------- def Color.hue2rgb.go2(+p: F32, +q: F32, +t: F32, c2: Bool) -> F32: Bool.pick(F32, c2, q, Bool.pick(F32, F32.is_lt(t, F32.div(2.0, 3.0)), F32.add(p, F32.mul( F32.mul(F32.sub(q, p), F32.sub(F32.div(2.0, 3.0), t)), 6.0)), p)) def Color.hue2rgb.go(+p: F32, +q: F32, +t: F32, c1: Bool) -> F32: Bool.pick(F32, c1, F32.add(p, F32.mul(F32.mul(F32.sub(q, p), 6.0), t)), Color.hue2rgb.go2(p, q, t, F32.is_lt(t, 0.5))) def Color.hue2rgb.wrap(+t: F32) -> F32: Bool.pick(F32, F32.is_lt(t, 0.0), F32.add(t, 1.0), Bool.pick(F32, F32.is_gt(t, 1.0), F32.sub(t, 1.0), t)) def Color.hue2rgb(+p: F32, +q: F32, +t0: F32) -> F32: +t = Color.hue2rgb.wrap(t0) Color.hue2rgb.go(p, q, t, F32.is_lt(t, F32.div(1.0, 6.0))) def Color.hsl_to_rgb.chromatic(+h: F32, +s: F32, +l: F32, +a: F32) -> Rgb: +q = Bool.pick(F32, F32.is_lt(l, 0.5), F32.mul(l, F32.add(1.0, s)), F32.sub(F32.add(l, s), F32.mul(l, s))) +p = F32.sub(F32.mul(2.0, l), q) +hn = F32.div(h, 360.0) rf = Color.hue2rgb(p, q, F32.add(hn, F32.div(1.0, 3.0))) gf = Color.hue2rgb(p, q, hn) bf = Color.hue2rgb(p, q, F32.sub(hn, F32.div(1.0, 3.0))) Rgb{ Color.channel_to_u8(rf), Color.channel_to_u8(gf), Color.channel_to_u8(bf), a} def Color.hsl_to_rgb(c: Hsl) -> Rgb: match c: case Hsl{+h, +s, +l, +a}: +gray = Color.channel_to_u8(l) Bool.pick(Rgb, F32.is_eq(s, 0.0), Rgb{gray, gray, gray, a}, Color.hsl_to_rgb.chromatic(h, s, l, a)) # Stable string formatting # ------------------------ # rgba(r,g,b,a) and hsla(h,s,l,a) with no spaces. RGB channels use U32.show; # floating fields use F32.show (runtime spelling; opaque to {==} proofs). def Color.to_rgba_string(c: Rgb) -> String: match c: case Rgb{r, g, b, a}: "rgba(" ++ U32.show(r) ++ "," ++ U32.show(g) ++ "," ++ U32.show(b) ++ "," ++ F32.show(a) ++ ")" def Color.to_hsla_string(c: Hsl) -> String: match c: case Hsl{h, s, l, a}: "hsla(" ++ F32.show(h) ++ "," ++ F32.show(s) ++ "," ++ F32.show(l) ++ "," ++ F32.show(a) ++ ")" # ColorRange validation (no RNG) # ------------------------------ def Color.range_rgb_le(a: Rgb, b: Rgb) -> Bool: match a b: case Rgb{ar, ag, ab, aa} Rgb{br, bg, bb, ba}: Bool.and(U32.is_le(ar, br), Bool.and(U32.is_le(ag, bg), Bool.and(U32.is_le(ab, bb), F32.is_le(aa, ba)))) def Color.range_valid(r: ColorRange) -> Bool: match r: case ColorRange{min, max}: Color.range_rgb_le(min, max)