diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/palette/AtmospherePresets.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/palette/AtmospherePresets.kt new file mode 100644 index 0000000..117791a --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/palette/AtmospherePresets.kt @@ -0,0 +1,134 @@ +package link.socket.phosphor.palette + +import link.socket.phosphor.signal.AtmospherePattern +import link.socket.phosphor.signal.AtmosphereState + +/** + * Canonical atmosphere presets for Lumos scene-global rendering. + * + * These presets use Socket-aligned default hues: 244-degree indigo, + * 197-degree cyan, 32-degree amber, and 280-degree purple. Consumers can + * construct their own [AtmosphereState] instances when a different palette or + * motion profile is needed. + */ +object AtmospherePresets { + val IDLE = + AtmosphereState( + primaryHue = 250f, + secondaryHue = 175f, + saturation = 0.85f, + lightness = 0.60f, + bipolarStrength = 0.0f, + pattern = AtmospherePattern.LONGITUDE, + patternSpeed = 0.25f, + pulseAmplitude = 0.025f, + pulseFrequency = 0.30f, + rotationY = 0.14f, + rotationX = 0.0f, + surfaceBump = 0.10f, + noise = 0.20f, + voxelGap = 0.05f, + ySquash = 0.95f, + resolution = 10, + glow = 1.0f, + ) + + val LISTENING = + AtmosphereState( + primaryHue = 195f, + secondaryHue = 270f, + saturation = 0.85f, + lightness = 0.60f, + bipolarStrength = 0.0f, + pattern = AtmospherePattern.PLASMA, + patternSpeed = 1.15f, + pulseAmplitude = 0.06f, + pulseFrequency = 0.5f, + rotationY = 0.16f, + rotationX = 0.10f, + surfaceBump = 0.10f, + noise = 0.20f, + voxelGap = 0.05f, + ySquash = 0.95f, + resolution = 10, + glow = 1.0f, + ) + + val THINKING = + AtmosphereState( + primaryHue = 244f, + secondaryHue = 185f, + saturation = 0.85f, + lightness = 0.60f, + bipolarStrength = 0.0f, + pattern = AtmospherePattern.SPIRAL, + patternSpeed = 0.7f, + pulseAmplitude = 0.025f, + pulseFrequency = 0.45f, + rotationY = 0.45f, + rotationX = 0.10f, + surfaceBump = 0.10f, + noise = 0.20f, + voxelGap = 0.05f, + ySquash = 0.95f, + resolution = 10, + glow = 1.0f, + ) + + val UNCERTAIN = + AtmosphereState( + primaryHue = 32f, + secondaryHue = 280f, + saturation = 0.85f, + lightness = 0.60f, + bipolarStrength = 0.45f, + pattern = AtmospherePattern.SPIRAL, + patternSpeed = 1.0f, + pulseAmplitude = 0.06f, + pulseFrequency = 0.16f, + rotationY = 0.10f, + rotationX = 0.10f, + surfaceBump = 0.10f, + noise = 0.20f, + voxelGap = 0.05f, + ySquash = 0.95f, + resolution = 10, + glow = 1.0f, + ) + + val READY = + AtmosphereState( + primaryHue = 249f, + secondaryHue = 197f, + saturation = 0.85f, + lightness = 0.60f, + bipolarStrength = 0.0f, + pattern = AtmospherePattern.PULSE, + patternSpeed = 1.3f, + pulseAmplitude = 0.04f, + pulseFrequency = 0.5f, + rotationY = 0.20f, + rotationX = -0.20f, + surfaceBump = 0.10f, + noise = 0.20f, + voxelGap = 0.05f, + ySquash = 0.95f, + resolution = 10, + glow = 1.0f, + ) + + val ALL: List> = + listOf( + "idle" to IDLE, + "listening" to LISTENING, + "thinking" to THINKING, + "uncertain" to UNCERTAIN, + "ready" to READY, + ) + + /** + * Resolve a canonical preset by name, ignoring case. + */ + fun byName(name: String): AtmosphereState? = + ALL.firstOrNull { (presetName, _) -> presetName.equals(name, ignoreCase = true) }?.second +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmospherePattern.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmospherePattern.kt new file mode 100644 index 0000000..dee9b49 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmospherePattern.kt @@ -0,0 +1,34 @@ +package link.socket.phosphor.signal + +import kotlinx.serialization.Serializable + +/** + * Scene-global pattern families used by [AtmosphereState]. + * + * Patterns describe spatial variation in the renderer's atmosphere. Surface + * adapters can interpret each family with renderer-specific math while keeping + * a stable signal contract in common code. + */ +@Serializable +enum class AtmospherePattern { + /** Horizontal-banded sine pattern around the vertical axis. */ + LONGITUDE, + + /** Vertical-banded sine pattern around the polar axis. */ + LATITUDE, + + /** Combined theta and phi sweep. */ + SPIRAL, + + /** Linear sweep along the Y axis. */ + SCAN, + + /** Three-axis noise sum. */ + PLASMA, + + /** Radial concentric rings from center. */ + PULSE, + + /** No pattern variation. */ + SOLID, +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmosphereState.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmosphereState.kt new file mode 100644 index 0000000..9f9b496 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmosphereState.kt @@ -0,0 +1,49 @@ +package link.socket.phosphor.signal + +import kotlinx.serialization.Serializable + +/** + * Scene-global visual parameter for renderer atmosphere. + * + * AtmosphereState describes the global environment through which rendered + * light propagates. It is not a per-agent state; [AgentVisualState] remains the + * per-agent counterpart for position, activity, and phase progress. + * + * @property primaryHue Primary hue in degrees, expected in 0..360. + * @property secondaryHue Secondary hue in degrees, expected in 0..360. + * @property saturation Color saturation, expected in 0..1. + * @property lightness Color lightness, expected in 0..1. + * @property bipolarStrength Two-pole color strength; values greater than zero enable bipolar color mode. + * @property pattern Spatial pattern family. + * @property patternSpeed Temporal scale for pattern movement. + * @property pulseAmplitude Radial scale modulation amplitude. + * @property pulseFrequency Radial scale modulation rate in Hz. + * @property rotationY Continuous spin rate around the Y axis. + * @property rotationX Continuous spin rate around the X axis. + * @property surfaceBump Surface deformation amplitude. + * @property noise Per-voxel position jitter scale. + * @property voxelGap Voxel scale-down amount used by renderers that show lattice gaps. + * @property ySquash Vertical squash ratio. + * @property resolution Lattice resolution. + * @property glow Renderer-interpreted atmospheric glow intensity. + */ +@Serializable +data class AtmosphereState( + val primaryHue: Float, + val secondaryHue: Float, + val saturation: Float, + val lightness: Float, + val bipolarStrength: Float, + val pattern: AtmospherePattern, + val patternSpeed: Float, + val pulseAmplitude: Float, + val pulseFrequency: Float, + val rotationY: Float, + val rotationX: Float, + val surfaceBump: Float, + val noise: Float, + val voxelGap: Float, + val ySquash: Float, + val resolution: Int, + val glow: Float, +) diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmosphereTransition.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmosphereTransition.kt new file mode 100644 index 0000000..8307645 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmosphereTransition.kt @@ -0,0 +1,58 @@ +package link.socket.phosphor.signal + +import kotlinx.serialization.Serializable + +/** + * Diagnostic snapshot for a transition between two atmosphere states. + * + * The transition carries both raw time progress and eased progress so renderer + * integrations can report exactly which interpolation input produced the + * current state. Preset names are nullable because either endpoint may be a + * caller-constructed [AtmosphereState]. + * + * Default preset transition table planned for AtmosphereChoreographer: + * + * | From -> To | Duration (s) | Easing | + * | --- | ---: | --- | + * | idle -> listening | 0.6 | eager | + * | idle -> thinking | 0.8 | easeOut | + * | idle -> uncertain | 1.4 | easeInOut | + * | idle -> ready | 1.1 | overshoot | + * | listening -> idle | 0.9 | easeInOut | + * | listening -> thinking | 0.75 | settled | + * | listening -> uncertain | 1.5 | easeInOut | + * | listening -> ready | 1.05 | overshoot | + * | thinking -> idle | 1.0 | easeInOut | + * | thinking -> listening | 0.65 | eager | + * | thinking -> uncertain | 1.65 | easeInOut | + * | thinking -> ready | 0.95 | overshoot | + * | uncertain -> idle | 1.15 | easeInOut | + * | uncertain -> listening | 0.6 | easeInOut | + * | uncertain -> thinking | 0.65 | easeInOut | + * | uncertain -> ready | 0.95 | easeInOut | + * | ready -> idle | 1.5 | easeInOut | + * | ready -> listening | 0.65 | eager | + * | ready -> thinking | 0.85 | settled | + * | ready -> uncertain | 1.7 | easeInOut | + * | default fallback | 1.1 | easeInOut | + * + * @property from Starting atmosphere state. + * @property to Target atmosphere state. + * @property fromPresetName Preset name for [from], or null when [from] is not a known preset. + * @property toPresetName Preset name for [to], or null when [to] is not a known preset. + * @property progressLinear Time-based progress, expected in 0..1. + * @property progressEased Easing-adjusted progress, expected in 0..1. + * @property easingName Easing identifier used for diagnostics. + * @property durationSeconds Transition duration in seconds. + */ +@Serializable +data class AtmosphereTransition( + val from: AtmosphereState, + val to: AtmosphereState, + val fromPresetName: String?, + val toPresetName: String?, + val progressLinear: Float, + val progressEased: Float, + val easingName: String, + val durationSeconds: Float, +) diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/palette/AtmospherePresetsTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/palette/AtmospherePresetsTest.kt new file mode 100644 index 0000000..7be1b44 --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/palette/AtmospherePresetsTest.kt @@ -0,0 +1,134 @@ +package link.socket.phosphor.palette + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import link.socket.phosphor.signal.AtmospherePattern +import link.socket.phosphor.signal.AtmosphereState + +class AtmospherePresetsTest { + @Test + fun `IDLE preset references are equal by value`() { + assertEquals(AtmospherePresets.IDLE, AtmospherePresets.IDLE) + } + + @Test + fun `byName returns expected atmosphere state case-insensitively`() { + assertEquals(AtmospherePresets.UNCERTAIN, AtmospherePresets.byName("uncertain")) + assertEquals(AtmospherePresets.UNCERTAIN, AtmospherePresets.byName("UnCeRtAiN")) + } + + @Test + fun `byName returns null for unknown preset`() { + assertNull(AtmospherePresets.byName("unknown")) + } + + @Test + fun `ALL contains five canonical presets`() { + assertEquals(5, AtmospherePresets.ALL.size) + assertEquals( + listOf("idle", "listening", "thinking", "uncertain", "ready"), + AtmospherePresets.ALL.map { (name, _) -> name }, + ) + } + + @Test + fun `canonical presets use locked prototype parameters`() { + assertEquals( + listOf( + "idle" to + state( + primaryHue = 250f, + secondaryHue = 175f, + bipolarStrength = 0.0f, + pattern = AtmospherePattern.LONGITUDE, + patternSpeed = 0.25f, + pulseAmplitude = 0.025f, + pulseFrequency = 0.30f, + rotationY = 0.14f, + rotationX = 0.0f, + ), + "listening" to + state( + primaryHue = 195f, + secondaryHue = 270f, + bipolarStrength = 0.0f, + pattern = AtmospherePattern.PLASMA, + patternSpeed = 1.15f, + pulseAmplitude = 0.06f, + pulseFrequency = 0.5f, + rotationY = 0.16f, + rotationX = 0.10f, + ), + "thinking" to + state( + primaryHue = 244f, + secondaryHue = 185f, + bipolarStrength = 0.0f, + pattern = AtmospherePattern.SPIRAL, + patternSpeed = 0.7f, + pulseAmplitude = 0.025f, + pulseFrequency = 0.45f, + rotationY = 0.45f, + rotationX = 0.10f, + ), + "uncertain" to + state( + primaryHue = 32f, + secondaryHue = 280f, + bipolarStrength = 0.45f, + pattern = AtmospherePattern.SPIRAL, + patternSpeed = 1.0f, + pulseAmplitude = 0.06f, + pulseFrequency = 0.16f, + rotationY = 0.10f, + rotationX = 0.10f, + ), + "ready" to + state( + primaryHue = 249f, + secondaryHue = 197f, + bipolarStrength = 0.0f, + pattern = AtmospherePattern.PULSE, + patternSpeed = 1.3f, + pulseAmplitude = 0.04f, + pulseFrequency = 0.5f, + rotationY = 0.20f, + rotationX = -0.20f, + ), + ), + AtmospherePresets.ALL, + ) + } + + private fun state( + primaryHue: Float, + secondaryHue: Float, + bipolarStrength: Float, + pattern: AtmospherePattern, + patternSpeed: Float, + pulseAmplitude: Float, + pulseFrequency: Float, + rotationY: Float, + rotationX: Float, + ): AtmosphereState = + AtmosphereState( + primaryHue = primaryHue, + secondaryHue = secondaryHue, + saturation = 0.85f, + lightness = 0.60f, + bipolarStrength = bipolarStrength, + pattern = pattern, + patternSpeed = patternSpeed, + pulseAmplitude = pulseAmplitude, + pulseFrequency = pulseFrequency, + rotationY = rotationY, + rotationX = rotationX, + surfaceBump = 0.10f, + noise = 0.20f, + voxelGap = 0.05f, + ySquash = 0.95f, + resolution = 10, + glow = 1.0f, + ) +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/signal/AtmosphereStateTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/signal/AtmosphereStateTest.kt new file mode 100644 index 0000000..ae80513 --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/signal/AtmosphereStateTest.kt @@ -0,0 +1,96 @@ +package link.socket.phosphor.signal + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.serialization.json.Json + +class AtmosphereStateTest { + @Test + fun `AtmosphereState supports JSON serialization`() { + val state = + AtmosphereState( + primaryHue = 244f, + secondaryHue = 197f, + saturation = 0.85f, + lightness = 0.6f, + bipolarStrength = 0.25f, + pattern = AtmospherePattern.PLASMA, + patternSpeed = 1.15f, + pulseAmplitude = 0.06f, + pulseFrequency = 0.5f, + rotationY = 0.16f, + rotationX = 0.1f, + surfaceBump = 0.1f, + noise = 0.2f, + voxelGap = 0.05f, + ySquash = 0.95f, + resolution = 10, + glow = 1.0f, + ) + + val json = Json { encodeDefaults = true } + val encoded = json.encodeToString(AtmosphereState.serializer(), state) + val decoded = json.decodeFromString(AtmosphereState.serializer(), encoded) + + assertEquals(state, decoded) + } + + @Test + fun `AtmosphereTransition supports JSON serialization`() { + val transition = + AtmosphereTransition( + from = + AtmosphereState( + primaryHue = 250f, + secondaryHue = 175f, + saturation = 0.85f, + lightness = 0.6f, + bipolarStrength = 0f, + pattern = AtmospherePattern.LONGITUDE, + patternSpeed = 0.25f, + pulseAmplitude = 0.025f, + pulseFrequency = 0.3f, + rotationY = 0.14f, + rotationX = 0f, + surfaceBump = 0.1f, + noise = 0.2f, + voxelGap = 0.05f, + ySquash = 0.95f, + resolution = 10, + glow = 1f, + ), + to = + AtmosphereState( + primaryHue = 249f, + secondaryHue = 197f, + saturation = 0.85f, + lightness = 0.6f, + bipolarStrength = 0f, + pattern = AtmospherePattern.PULSE, + patternSpeed = 1.3f, + pulseAmplitude = 0.04f, + pulseFrequency = 0.5f, + rotationY = 0.2f, + rotationX = -0.2f, + surfaceBump = 0.1f, + noise = 0.2f, + voxelGap = 0.05f, + ySquash = 0.95f, + resolution = 10, + glow = 1f, + ), + fromPresetName = "idle", + toPresetName = "ready", + progressLinear = 0.5f, + progressEased = 0.72f, + easingName = "overshoot", + durationSeconds = 1.1f, + ) + + val json = Json { encodeDefaults = true } + val encoded = json.encodeToString(AtmosphereTransition.serializer(), transition) + val decoded = json.decodeFromString(AtmosphereTransition.serializer(), encoded) + + assertEquals(transition, decoded) + } +}