diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/ColorRamp.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/ColorRamp.kt index 9f6eeaa..5a1651d 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/ColorRamp.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/ColorRamp.kt @@ -16,6 +16,24 @@ data class ColorRamp( * Sample the ramp at t in [0, 1], linearly interpolating between stops. */ fun sample(t: Float): NeutralColor { + return sampleWith(t, NeutralColor::lerp) + } + + /** + * Sample the ramp at t in [0, 1], interpolating between stops in OKLab. + * + * Prefer [sample] for nearby hues and performance-sensitive render-loop + * paths. Use [sampleOklab] for ramps that bridge distant hues where + * perceptual uniformity matters more than the extra conversion cost. + */ + fun sampleOklab(t: Float): NeutralColor { + return sampleWith(t, NeutralColor::lerpOklab) + } + + private inline fun sampleWith( + t: Float, + interpolate: (NeutralColor, NeutralColor, Float) -> NeutralColor, + ): NeutralColor { val clamped = t.coerceIn(0f, 1f) if (clamped <= 0f) return stops.first() if (clamped >= 1f) return stops.last() @@ -27,7 +45,7 @@ data class ColorRamp( if (lower == upper) return stops[lower] val localT = scaled - lower - return NeutralColor.lerp(stops[lower], stops[upper], localT) + return interpolate(stops[lower], stops[upper], localT) } companion object { diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.kt index 260669d..6aeb773 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.kt @@ -149,6 +149,37 @@ value class NeutralColor private constructor( ) } + /** + * Interpolate between two sRGB colors in OKLab perceptual space. + * + * This is opt-in because it performs extra color-space conversions. Use + * [lerp] for nearby hues and performance-sensitive render-loop paths; use + * [lerpOklab] for distant hues where linear RGB would pass through muddy + * or hue-biased midpoints. + */ + fun lerpOklab( + start: NeutralColor, + end: NeutralColor, + t: Float, + ): NeutralColor { + val clamped = t.coerceIn(0f, 1f) + val color = + OklabColor + .lerp( + start = OklabColor.fromSrgb(start), + end = OklabColor.fromSrgb(end), + t = clamped, + ) + .toSrgb(alpha = interpolate(start.alpha, end.alpha, clamped)) + + return fromRgba( + red = color.red.coerceIn(0f, 1f), + green = color.green.coerceIn(0f, 1f), + blue = color.blue.coerceIn(0f, 1f), + alpha = color.alpha.coerceIn(0f, 1f), + ) + } + private fun interpolate( start: Float, end: Float, diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/OklabColor.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/OklabColor.kt new file mode 100644 index 0000000..b7e0c96 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/OklabColor.kt @@ -0,0 +1,168 @@ +package link.socket.phosphor.color + +import kotlin.math.pow + +/** + * Linear RGB color with normalized channels. + * + * This is the linear-light form of Phosphor's sRGB [NeutralColor]. Use it as + * the bridge between packed sRGB and [OklabColor] when a caller needs explicit + * color-space conversion. For interpolation, prefer [NeutralColor.lerp] for + * nearby hues and performance-sensitive paths; prefer [NeutralColor.lerpOklab] + * for distant hues where perceptual uniformity matters. + */ +data class LinearRgbColor( + val red: Float, + val green: Float, + val blue: Float, +) { + /** + * Convert this linear RGB color to packed sRGB. + * + * Channels outside the displayable sRGB gamut are clamped by [NeutralColor]. + */ + fun toSrgb(alpha: Float = 1f): NeutralColor = + NeutralColor.fromRgba( + red = linearToSrgb(red), + green = linearToSrgb(green), + blue = linearToSrgb(blue), + alpha = alpha, + ) + + /** + * Convert this linear RGB color to OKLab using Bjorn Ottosson's published matrices. + */ + fun toOklab(): OklabColor = OklabColor.fromLinearRgb(this) + + companion object { + /** + * Decode a packed sRGB [NeutralColor] into linear-light RGB. + */ + fun fromSrgb(color: NeutralColor): LinearRgbColor = + LinearRgbColor( + red = srgbToLinear(color.red), + green = srgbToLinear(color.green), + blue = srgbToLinear(color.blue), + ) + } +} + +/** + * OKLab color in perceptually uniform L, a, b coordinates. + * + * OKLab interpolation is useful when moving between distant hues because equal + * steps better match perceived color movement. Keep using [NeutralColor.lerp] + * or [ColorRamp.sample] for nearby hues and hot render-loop code; use + * [NeutralColor.lerpOklab] or [ColorRamp.sampleOklab] when clean perceptual + * handoffs matter more than the extra conversion cost. + */ +data class OklabColor( + val lightness: Float, + val a: Float, + val b: Float, +) { + /** + * Convert this OKLab color to linear RGB using Bjorn Ottosson's published matrices. + */ + fun toLinearRgb(): LinearRgbColor { + val lightness = lightness.toDouble() + val a = a.toDouble() + val b = b.toDouble() + + val lPrime = lightness + (0.3963377774 * a) + (0.2158037573 * b) + val mPrime = lightness - (0.1055613458 * a) - (0.0638541728 * b) + val sPrime = lightness - (0.0894841775 * a) - (1.2914855480 * b) + + val l = lPrime * lPrime * lPrime + val m = mPrime * mPrime * mPrime + val s = sPrime * sPrime * sPrime + + return LinearRgbColor( + red = ((4.0767416621 * l) - (3.3077115913 * m) + (0.2309699292 * s)).toFloat(), + green = ((-1.2684380046 * l) + (2.6097574011 * m) - (0.3413193965 * s)).toFloat(), + blue = ((-0.0041960863 * l) - (0.7034186147 * m) + (1.7076147010 * s)).toFloat(), + ) + } + + /** + * Convert this OKLab color directly to packed sRGB. + */ + fun toSrgb(alpha: Float = 1f): NeutralColor = toLinearRgb().toSrgb(alpha) + + companion object { + /** + * Convert packed sRGB to OKLab through linear RGB. + */ + fun fromSrgb(color: NeutralColor): OklabColor = LinearRgbColor.fromSrgb(color).toOklab() + + /** + * Convert linear RGB to OKLab using Bjorn Ottosson's published matrices. + */ + fun fromLinearRgb(color: LinearRgbColor): OklabColor { + val red = color.red.toDouble() + val green = color.green.toDouble() + val blue = color.blue.toDouble() + + val l = (0.4122214708 * red) + (0.5363325363 * green) + (0.0514459929 * blue) + val m = (0.2119034982 * red) + (0.6806995451 * green) + (0.1073969566 * blue) + val s = (0.0883024619 * red) + (0.2817188376 * green) + (0.6299787005 * blue) + + val lPrime = signedCubeRoot(l) + val mPrime = signedCubeRoot(m) + val sPrime = signedCubeRoot(s) + + return OklabColor( + lightness = ((0.2104542553 * lPrime) + (0.7936177850 * mPrime) - (0.0040720468 * sPrime)).toFloat(), + a = ((1.9779984951 * lPrime) - (2.4285922050 * mPrime) + (0.4505937099 * sPrime)).toFloat(), + b = ((0.0259040371 * lPrime) + (0.7827717662 * mPrime) - (0.8086757660 * sPrime)).toFloat(), + ) + } + + /** + * Interpolate between two OKLab colors in perceptual coordinates. + */ + fun lerp( + start: OklabColor, + end: OklabColor, + t: Float, + ): OklabColor { + val clamped = t.coerceIn(0f, 1f) + return OklabColor( + lightness = interpolate(start.lightness, end.lightness, clamped), + a = interpolate(start.a, end.a, clamped), + b = interpolate(start.b, end.b, clamped), + ) + } + } +} + +private fun srgbToLinear(channel: Float): Float { + val value = channel.coerceIn(0f, 1f).toDouble() + return if (value <= 0.04045) { + (value / 12.92).toFloat() + } else { + (((value + 0.055) / 1.055).pow(2.4)).toFloat() + } +} + +private fun linearToSrgb(channel: Float): Float { + val value = channel.toDouble() + return if (value <= 0.0031308) { + (12.92 * value).toFloat() + } else { + ((1.055 * value.pow(1.0 / 2.4)) - 0.055).toFloat() + } +} + +private fun signedCubeRoot(value: Double): Double = + when { + value < 0.0 -> -((-value).pow(1.0 / 3.0)) + value > 0.0 -> value.pow(1.0 / 3.0) + else -> 0.0 + } + +private fun interpolate( + start: Float, + end: Float, + t: Float, +): Float = start + ((end - start) * t) diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/OklabColorTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/OklabColorTest.kt new file mode 100644 index 0000000..dad0c85 --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/OklabColorTest.kt @@ -0,0 +1,143 @@ +package link.socket.phosphor.color + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import link.socket.phosphor.signal.CognitivePhase + +class OklabColorTest { + @Test + fun `srgb to oklab to srgb preserves sampled colors`() { + val colors = + listOf( + NeutralColor.BLACK, + NeutralColor.WHITE, + NeutralColor.fromHex("#FF0000"), + NeutralColor.fromHex("#00FF00"), + NeutralColor.fromHex("#0000FF"), + NeutralColor.fromHex("#3366CC"), + NeutralColor.fromHex("#C89A65"), + NeutralColor.fromHex("#8C46AF"), + NeutralColor.fromHex("#22334480"), + ) + + colors.forEach { color -> + val roundTripped = OklabColor.fromSrgb(color).toSrgb(alpha = color.alpha) + + assertColorClose(color, roundTripped, tolerance = 1e-4f) + } + } + + @Test + fun `oklab midpoint avoids linear rgb amber to purple midpoint`() { + val amber = NeutralColor.fromHex("#C89A65") + val purple = NeutralColor.fromHex("#8C46AF") + + val linearMidpoint = NeutralColor.lerp(amber, purple, 0.5f) + val perceptualMidpoint = NeutralColor.lerpOklab(amber, purple, 0.5f) + + assertEquals("#AA708AFF", linearMidpoint.toHex()) + assertEquals("#A87592FF", perceptualMidpoint.toHex()) + assertNotEquals(linearMidpoint, perceptualMidpoint) + } + + @Test + fun `oklab lerp preserves identity color`() { + val red = NeutralColor.fromHex("#FF0000") + + assertEquals(red, NeutralColor.lerpOklab(red, red, 0.5f)) + } + + @Test + fun `oklab lerp from black to white produces perceptual neutral grey`() { + val midpoint = NeutralColor.lerpOklab(NeutralColor.BLACK, NeutralColor.WHITE, 0.5f) + + assertEquals(midpoint.redInt, midpoint.greenInt) + assertEquals(midpoint.greenInt, midpoint.blueInt) + assertEquals("#636363FF", midpoint.toHex()) + } + + @Test + fun `ColorRamp sample remains linear rgb for existing ramps`() { + val ramps = + listOf( + CognitiveColorModel.phaseRampFor(CognitivePhase.PERCEIVE), + CognitiveColorModel.phaseRampFor(CognitivePhase.EXECUTE), + CognitiveColorModel.confidenceRamp, + CognitiveColorModel.flowIntensityRamp, + ) + val samples = listOf(-1f, 0f, 0.125f, 0.5f, 0.875f, 1f, 2f) + + ramps.forEach { ramp -> + samples.forEach { t -> + assertEquals(expectedLinearSample(ramp, t), ramp.sample(t)) + } + } + } + + @Test + fun `ColorRamp sampleOklab uses perceptual interpolation`() { + val ramp = + ColorRamp( + stops = + listOf( + NeutralColor.fromHex("#C89A65"), + NeutralColor.fromHex("#8C46AF"), + ), + ) + + assertEquals(NeutralColor.lerpOklab(ramp.stops.first(), ramp.stops.last(), 0.5f), ramp.sampleOklab(0.5f)) + } + + private fun expectedLinearSample( + ramp: ColorRamp, + t: Float, + ): NeutralColor { + val clamped = t.coerceIn(0f, 1f) + if (clamped <= 0f) return ramp.stops.first() + if (clamped >= 1f) return ramp.stops.last() + + val lastIndex = ramp.stops.lastIndex + val scaled = clamped * lastIndex + val lower = scaled.toInt().coerceIn(0, lastIndex) + val upper = (lower + 1).coerceIn(0, lastIndex) + + if (lower == upper) return ramp.stops[lower] + + val localT = scaled - lower + val start = ramp.stops[lower] + val end = ramp.stops[upper] + + return NeutralColor.fromRgba( + red = interpolate(start.red, end.red, localT), + green = interpolate(start.green, end.green, localT), + blue = interpolate(start.blue, end.blue, localT), + alpha = interpolate(start.alpha, end.alpha, localT), + ) + } + + private fun interpolate( + start: Float, + end: Float, + t: Float, + ): Float = start + ((end - start) * t) + + private fun assertColorClose( + expected: NeutralColor, + actual: NeutralColor, + tolerance: Float, + ) { + assertTrue(abs(expected.red - actual.red) <= tolerance, "red expected ${expected.red}, got ${actual.red}") + assertTrue( + abs(expected.green - actual.green) <= tolerance, + "green expected ${expected.green}, got ${actual.green}", + ) + assertTrue(abs(expected.blue - actual.blue) <= tolerance, "blue expected ${expected.blue}, got ${actual.blue}") + assertTrue( + abs(expected.alpha - actual.alpha) <= tolerance, + "alpha expected ${expected.alpha}, got ${actual.alpha}", + ) + } +}