From 3db36f245dbbc9f381b17503a61cd3de93eb4828 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 24 May 2026 18:10:27 -0500 Subject: [PATCH] Add AtmosphereChoreographer for atmosphere transitions Introduces AtmosphereChoreographer with phase-accumulator integration, two-track easing (eased amplitudes, linear crossfade weights), color snapshots for bipolar transitions, and pattern crossfade. Wires it into CognitiveSceneRuntime between agent and emitter ticks, surfacing SceneSnapshot.atmosphereTransition. Adds eager, reluctant, overshoot, and settled easings. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md | 48 ++- .../choreography/AtmosphereChoreographer.kt | 360 ++++++++++++++++++ .../phosphor/runtime/CognitiveSceneRuntime.kt | 68 +++- .../socket/phosphor/runtime/SceneSnapshot.kt | 4 + .../link/socket/phosphor/timeline/Easing.kt | 45 +++ .../AtmosphereChoreographerTest.kt | 221 +++++++++++ .../runtime/CognitiveSceneRuntimeTest.kt | 54 ++- 7 files changed, 771 insertions(+), 29 deletions(-) create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographerTest.kt diff --git a/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md b/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md index f6c9d65..d7cd405 100644 --- a/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md +++ b/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md @@ -17,6 +17,20 @@ Consumers manually advanced every subsystem in the frame loop: ## After +`runtime.update(dtSeconds)` now executes nine ordered steps. Atmosphere +advances between agent state and emitter emission so downstream consumers tick +against the latest scene-global character: + +1. `CognitiveChoreographer` phase advance (per-agent transitions, substrate effects) +2. Ambient substrate animation +3. Agent layer update +4. `AtmosphereChoreographer.update(dt)` — interpolates `AtmosphereState`, advances phase accumulators, surfaces crossfade snapshots +5. Emitter emission pass and lifecycle update +6. Particle simulation +7. Flow field advection +8. Waveform sampling +9. Camera orbit + Consumers call `runtime.update(dtSeconds)` and render from the returned `SceneSnapshot`. ```kotlin @@ -53,16 +67,30 @@ class RuntimeAdapter( } ``` -## Atmosphere Snapshot Field - -Atmosphere is a passive runtime subsystem. It is disabled by default, so -`SceneSnapshot.atmosphere` is `null` unless `SceneConfiguration.enableAtmosphere` -is set to `true`. - -When enabled, the runtime starts from `SceneConfiguration.initialAtmosphere`, -which defaults to `AtmospherePresets.IDLE`. Calls to `runtime.setAtmosphere(state)` -replace the current value, and the next snapshot exposes that value without -interpolation. Lumos consumes the atmosphere via its renderer. +## Atmosphere Snapshot Fields + +Atmosphere is an opt-in subsystem. `SceneSnapshot.atmosphere` and +`SceneSnapshot.atmosphereTransition` are both `null` unless +`SceneConfiguration.enableAtmosphere` is set to `true`. + +When enabled, the runtime constructs an `AtmosphereChoreographer` seeded with +`SceneConfiguration.initialAtmosphere` (defaults to `AtmospherePresets.IDLE`). +Two entry points request a transition to a new atmosphere: + +- `runtime.setAtmosphere(state)` — accepts any `AtmosphereState`; the + choreographer reverse-looks-up the value against the preset table to resolve + a tabled transition spec, falling back to the default 1.1s easeInOut. +- `runtime.setAtmospherePreset(name)` — case-insensitive lookup via + `AtmospherePresets.byName`; throws `IllegalArgumentException` for unknown + names. The resolved preset identifier is passed to the choreographer so the + default transition table can match by name. + +`SceneSnapshot.atmosphere` exposes the interpolated state for the current +tick. `SceneSnapshot.atmosphereTransition` is non-null while a transition is +in progress and carries linear and eased progress, the easing identifier, the +duration, and the from/to endpoints. Renderers (Lumos) consume both fields to +crossfade patterns and to blend bipolar color configurations in OKLab space +via the snapshots surfaced by the choreographer. ## Notes diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.kt new file mode 100644 index 0000000..fe67d00 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.kt @@ -0,0 +1,360 @@ +package link.socket.phosphor.choreography + +import kotlin.math.roundToInt +import link.socket.phosphor.palette.AtmospherePresets +import link.socket.phosphor.signal.AtmospherePattern +import link.socket.phosphor.signal.AtmosphereState +import link.socket.phosphor.signal.AtmosphereTransition +import link.socket.phosphor.timeline.Easing + +/** + * Color-defining parameters captured at transition start. + * + * Surfaced by [AtmosphereChoreographer] when a transition involves a + * bipolar-strength change. Renderers consume both the from- and to-snapshot + * each frame, evaluate their color output independently, and blend in OKLab + * space using [AtmosphereChoreographer.colorBlend]. This avoids interpolating + * hues through forbidden palette regions (for example amber to indigo via + * pink). + * + * @property primaryHue Primary hue in degrees, captured at transition start. + * @property secondaryHue Secondary hue in degrees, captured at transition start. + * @property bipolarStrength Two-pole color strength, captured at transition start. + */ +data class AtmosphereColorSnapshot( + val primaryHue: Float, + val secondaryHue: Float, + val bipolarStrength: Float, +) + +/** + * Specification for a single atmosphere transition: duration and easing identifier. + * + * The easing identifier is resolved against [Easing.byName] at transition + * start; unknown identifiers fall back to [Easing.easeInOut]. + * + * @property durationSeconds Transition duration in seconds; must be > 0. + * @property easingName Easing identifier resolved via [Easing.byName]. + */ +data class AtmosphereTransitionSpec( + val durationSeconds: Float, + val easingName: String, +) + +/** + * Continuous interpolator for [AtmosphereState] transitions. + * + * The choreographer owns the running atmosphere value; [setAtmosphere] + * requests a transition to a new target and [update] advances the + * interpolation one tick at a time. The implementation encodes four + * cross-cutting concerns that the Lumos prototype validated: + * + * - **Phase accumulators.** [pulsePhase] and [patternPhase] are integrated as + * `dt * frequency` each frame so callers driving sine waves do not see + * phase discontinuities when [AtmosphereState.pulseFrequency] or + * [AtmosphereState.patternSpeed] changes during a transition. + * - **Two-track easing.** Numeric amplitude parameters use eased progress + * (e = easingFn(t)). Crossfade weights ([colorBlend], [patternBlend]) use + * linear progress so the visible blend animates evenly across the window. + * - **Snapshot color crossfade.** When a transition involves a bipolar-strength + * change between distinct hues, [colorFromSnapshot] and [colorToSnapshot] + * capture both color configurations at transition start so renderers can + * blend them in OKLab space rather than lerping hue floats through unwanted + * palette regions. + * - **Pattern crossfade.** When the [AtmosphereState.pattern] changes, + * [patternFrom] holds the source pattern alongside a linear [patternBlend] + * value; downstream consumers compute the mix for both patterns and blend. + * + * The choreographer is pure data and math — it surfaces the values renderers + * need but performs no rendering itself. + * + * @param initialAtmosphere Starting atmosphere value; becomes [currentState]. + */ +class AtmosphereChoreographer( + initialAtmosphere: AtmosphereState, +) { + /** Currently interpolated atmosphere state. Returned from [update]. */ + var currentState: AtmosphereState = initialAtmosphere + private set + + /** Non-null while a transition is in progress; null before and after. */ + var activeTransition: AtmosphereTransition? = null + private set + + /** + * Continuous pulse phase advanced each frame by `dt * pulseFrequency`. + * + * Continuity is preserved across atmosphere changes so frequencies can be + * interpolated without phase discontinuities. + */ + var pulsePhase: Float = 0f + private set + + /** + * Continuous pattern phase advanced each frame by `dt * patternSpeed`. + * + * Continuity is preserved across atmosphere changes for the same reason + * as [pulsePhase]. + */ + var patternPhase: Float = 0f + private set + + /** + * Snapshot of the source-side color configuration during an active + * transition. Null when no transition is active or when the transition + * does not need crossfading (no bipolar-strength change or matching hues). + */ + var colorFromSnapshot: AtmosphereColorSnapshot? = null + private set + + /** + * Snapshot of the target-side color configuration during an active + * transition. Null under the same conditions as [colorFromSnapshot]. + */ + var colorToSnapshot: AtmosphereColorSnapshot? = null + private set + + /** Linear progress in 0..1 for color crossfade; 0 when no transition is active. */ + var colorBlend: Float = 0f + private set + + /** Linear progress in 0..1 for pattern crossfade; 0 when no transition is active. */ + var patternBlend: Float = 0f + private set + + /** Source pattern during transitions where the pattern changes; null otherwise. */ + var patternFrom: AtmospherePattern? = null + private set + + private var sourceState: AtmosphereState = initialAtmosphere + private var sourcePresetName: String? = reverseLookupPresetName(initialAtmosphere) + private var targetState: AtmosphereState = initialAtmosphere + private var targetPresetNameInternal: String? = sourcePresetName + private var elapsedSeconds: Float = 0f + private var durationSeconds: Float = 0f + private var easingName: String = "linear" + private var easingFn: (Float) -> Float = Easing.linear + + /** + * Replace the current atmosphere with [target], starting a transition. + * + * The transition spec is resolved from the default table by matching + * the from- and to-preset names against [AtmospherePresets.ALL]; when no + * match is found the lookup falls back to [DefaultSpec] (1.1s easeInOut). + * + * If [targetPresetName] is null, the choreographer attempts to identify + * [target] as a known preset via equality against [AtmospherePresets.ALL]. + * The from-preset name is always determined by reverse-lookup against the + * current interpolated state. + * + * Calling this mid-transition begins a new transition whose source is the + * current interpolated state — not the prior target. + * + * Phase accumulators are not touched here. + * + * @param target New atmosphere value. + * @param targetPresetName Optional caller-supplied preset identifier for [target]. + */ + fun setAtmosphere( + target: AtmosphereState, + targetPresetName: String? = null, + ) { + val resolvedTargetName = targetPresetName ?: reverseLookupPresetName(target) + val resolvedFromName = reverseLookupPresetName(currentState) ?: sourcePresetName + val spec = resolveSpec(resolvedFromName, resolvedTargetName) + val source = currentState + + sourceState = source + sourcePresetName = resolvedFromName + targetState = target + targetPresetNameInternal = resolvedTargetName + elapsedSeconds = 0f + durationSeconds = spec.durationSeconds + easingName = spec.easingName + easingFn = Easing.byName(spec.easingName) ?: Easing.easeInOut + + patternFrom = if (source.pattern != target.pattern) source.pattern else null + patternBlend = 0f + + val huesDiffer = + source.primaryHue != target.primaryHue || + source.secondaryHue != target.secondaryHue + val anyBipolar = source.bipolarStrength > 0f || target.bipolarStrength > 0f + if (anyBipolar && huesDiffer) { + colorFromSnapshot = + AtmosphereColorSnapshot( + primaryHue = source.primaryHue, + secondaryHue = source.secondaryHue, + bipolarStrength = source.bipolarStrength, + ) + colorToSnapshot = + AtmosphereColorSnapshot( + primaryHue = target.primaryHue, + secondaryHue = target.secondaryHue, + bipolarStrength = target.bipolarStrength, + ) + } else { + colorFromSnapshot = null + colorToSnapshot = null + } + colorBlend = 0f + + activeTransition = + AtmosphereTransition( + from = source, + to = target, + fromPresetName = resolvedFromName, + toPresetName = resolvedTargetName, + progressLinear = 0f, + progressEased = 0f, + easingName = easingName, + durationSeconds = durationSeconds, + ) + } + + /** + * Advance the choreographer by [dt] seconds. + * + * When no transition is active, returns [currentState] unchanged but still + * integrates [pulsePhase] and [patternPhase] using the current state's + * frequencies. + * + * @param dt Frame delta in seconds; must be >= 0. + * @return The interpolated atmosphere state for this tick. + */ + fun update(dt: Float): AtmosphereState { + require(dt >= 0f) { "dt must be >= 0, got $dt" } + + val transition = activeTransition + if (transition == null) { + pulsePhase += dt * currentState.pulseFrequency + patternPhase += dt * currentState.patternSpeed + return currentState + } + + elapsedSeconds += dt + val linear = + if (durationSeconds <= 0f) { + 1f + } else { + (elapsedSeconds / durationSeconds).coerceIn(0f, 1f) + } + val eased = easingFn(linear).coerceIn(0f, 1f) + + val interpolated = interpolate(sourceState, targetState, eased) + currentState = interpolated + colorBlend = linear + patternBlend = linear + + pulsePhase += dt * interpolated.pulseFrequency + patternPhase += dt * interpolated.patternSpeed + + if (linear >= 1f) { + currentState = targetState + sourceState = targetState + sourcePresetName = targetPresetNameInternal + activeTransition = null + colorFromSnapshot = null + colorToSnapshot = null + patternFrom = null + colorBlend = 0f + patternBlend = 0f + } else { + activeTransition = + AtmosphereTransition( + from = sourceState, + to = targetState, + fromPresetName = sourcePresetName, + toPresetName = targetPresetNameInternal, + progressLinear = linear, + progressEased = eased, + easingName = easingName, + durationSeconds = durationSeconds, + ) + } + + return currentState + } + + private fun interpolate( + from: AtmosphereState, + to: AtmosphereState, + t: Float, + ): AtmosphereState = + AtmosphereState( + primaryHue = lerp(from.primaryHue, to.primaryHue, t), + secondaryHue = lerp(from.secondaryHue, to.secondaryHue, t), + saturation = lerp(from.saturation, to.saturation, t), + lightness = lerp(from.lightness, to.lightness, t), + bipolarStrength = lerp(from.bipolarStrength, to.bipolarStrength, t), + pattern = to.pattern, + patternSpeed = lerp(from.patternSpeed, to.patternSpeed, t), + pulseAmplitude = lerp(from.pulseAmplitude, to.pulseAmplitude, t), + pulseFrequency = lerp(from.pulseFrequency, to.pulseFrequency, t), + rotationY = lerp(from.rotationY, to.rotationY, t), + rotationX = lerp(from.rotationX, to.rotationX, t), + surfaceBump = lerp(from.surfaceBump, to.surfaceBump, t), + noise = lerp(from.noise, to.noise, t), + voxelGap = lerp(from.voxelGap, to.voxelGap, t), + ySquash = lerp(from.ySquash, to.ySquash, t), + resolution = lerp(from.resolution.toFloat(), to.resolution.toFloat(), t).roundToInt(), + glow = lerp(from.glow, to.glow, t), + ) + + private fun lerp( + from: Float, + to: Float, + t: Float, + ): Float = from + (to - from) * t + + private fun reverseLookupPresetName(state: AtmosphereState): String? = + AtmospherePresets.ALL.firstOrNull { (_, presetState) -> presetState == state }?.first + + private fun resolveSpec( + fromName: String?, + toName: String?, + ): AtmosphereTransitionSpec { + if (fromName == null || toName == null) return DefaultSpec + return DefaultTransitionTable[fromName to toName] ?: DefaultSpec + } + + companion object { + /** + * Fallback transition spec used when the lookup against + * [DefaultTransitionTable] does not match. 1.1 seconds, easeInOut. + */ + val DefaultSpec: AtmosphereTransitionSpec = + AtmosphereTransitionSpec(durationSeconds = 1.1f, easingName = "easeInOut") + + /** + * Canonical transition table for the five Lumos atmosphere presets. + * + * Keys are `(fromPresetName, toPresetName)` pairs matching the lower-case + * names registered in [AtmospherePresets.ALL]. Any pair not present in + * this map resolves to [DefaultSpec]. + */ + private val DefaultTransitionTable: Map, AtmosphereTransitionSpec> = + mapOf( + ("idle" to "listening") to AtmosphereTransitionSpec(0.6f, "eager"), + ("idle" to "thinking") to AtmosphereTransitionSpec(0.8f, "easeOut"), + ("idle" to "uncertain") to AtmosphereTransitionSpec(1.4f, "easeInOut"), + ("idle" to "ready") to AtmosphereTransitionSpec(1.1f, "overshoot"), + ("listening" to "idle") to AtmosphereTransitionSpec(0.9f, "easeInOut"), + ("listening" to "thinking") to AtmosphereTransitionSpec(0.75f, "settled"), + ("listening" to "uncertain") to AtmosphereTransitionSpec(1.5f, "easeInOut"), + ("listening" to "ready") to AtmosphereTransitionSpec(1.05f, "overshoot"), + ("thinking" to "idle") to AtmosphereTransitionSpec(1.0f, "easeInOut"), + ("thinking" to "listening") to AtmosphereTransitionSpec(0.65f, "eager"), + ("thinking" to "uncertain") to AtmosphereTransitionSpec(1.65f, "easeInOut"), + ("thinking" to "ready") to AtmosphereTransitionSpec(0.95f, "overshoot"), + ("uncertain" to "idle") to AtmosphereTransitionSpec(1.15f, "easeInOut"), + ("uncertain" to "listening") to AtmosphereTransitionSpec(0.6f, "easeInOut"), + ("uncertain" to "thinking") to AtmosphereTransitionSpec(0.65f, "easeInOut"), + ("uncertain" to "ready") to AtmosphereTransitionSpec(0.95f, "easeInOut"), + ("ready" to "idle") to AtmosphereTransitionSpec(1.5f, "easeInOut"), + ("ready" to "listening") to AtmosphereTransitionSpec(0.65f, "eager"), + ("ready" to "thinking") to AtmosphereTransitionSpec(0.85f, "settled"), + ("ready" to "uncertain") to AtmosphereTransitionSpec(1.7f, "easeInOut"), + ) + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt index fce3cef..f16efee 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt @@ -3,6 +3,7 @@ package link.socket.phosphor.runtime import kotlin.random.Random import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.choreography.AgentLayoutOrientation +import link.socket.phosphor.choreography.AtmosphereChoreographer import link.socket.phosphor.choreography.CognitiveChoreographer import link.socket.phosphor.emitter.EmitterEffect import link.socket.phosphor.emitter.EmitterManager @@ -13,6 +14,7 @@ import link.socket.phosphor.field.ParticleSystem import link.socket.phosphor.field.SubstrateAnimator import link.socket.phosphor.field.SubstrateState import link.socket.phosphor.math.Vector3 +import link.socket.phosphor.palette.AtmospherePresets import link.socket.phosphor.render.Camera import link.socket.phosphor.render.CameraOrbit import link.socket.phosphor.render.CognitiveWaveform @@ -77,11 +79,15 @@ class CognitiveSceneRuntime( ) } - private var atmosphereState: AtmosphereState? = - if (configuration.enableAtmosphere) configuration.initialAtmosphere else null + val atmosphereChoreographer: AtmosphereChoreographer? = + if (configuration.enableAtmosphere) { + AtmosphereChoreographer(configuration.initialAtmosphere) + } else { + null + } val currentAtmosphere: AtmosphereState? - get() = atmosphereState + get() = atmosphereChoreographer?.currentState private var substrateState: SubstrateState = SubstrateState.create( @@ -119,13 +125,43 @@ class CognitiveSceneRuntime( fun snapshot(): SceneSnapshot = latestSnapshot /** - * Replace the current atmosphere state. + * Replace the current atmosphere state and begin a transition. + * + * Delegates to [AtmosphereChoreographer.setAtmosphere]. The from-preset + * name is inferred by reverse-lookup against [AtmospherePresets.ALL]; the + * to-preset name is also inferred (so callers using a known preset value + * still get a tabled transition). Callers that need to bind a specific + * preset name should use [setAtmospherePreset]. */ fun setAtmosphere(state: AtmosphereState) { - check(configuration.enableAtmosphere) { - "Atmosphere subsystem not enabled in SceneConfiguration. Set enableAtmosphere = true to use setAtmosphere." - } - atmosphereState = state + val choreographer = + checkNotNull(atmosphereChoreographer) { + "Atmosphere subsystem not enabled in SceneConfiguration. " + + "Set enableAtmosphere = true to use setAtmosphere." + } + choreographer.setAtmosphere(state, targetPresetName = null) + } + + /** + * Replace the current atmosphere with the preset registered as [name]. + * + * Lookup is case-insensitive via [AtmospherePresets.byName]. The resolved + * preset name is forwarded to the choreographer so transition specs can be + * looked up in the default table. + * + * @throws IllegalArgumentException when [name] does not match any registered preset. + */ + fun setAtmospherePreset(name: String) { + val choreographer = + checkNotNull(atmosphereChoreographer) { + "Atmosphere subsystem not enabled in SceneConfiguration. " + + "Set enableAtmosphere = true to use setAtmospherePreset." + } + val state = + requireNotNull(AtmospherePresets.byName(name)) { + "Unknown atmosphere preset: '$name'" + } + choreographer.setAtmosphere(state, targetPresetName = name) } /** @@ -149,30 +185,31 @@ class CognitiveSceneRuntime( // 3) Agent state update. agents.update(deltaTimeSeconds) - // PHO-X4 will add AtmosphereChoreographer.update(dt) here + // 4) Atmosphere interpolation advance. + atmosphereChoreographer?.update(deltaTimeSeconds) - // 4) Emitter emission pass and lifecycle update. + // 5) Emitter emission pass and lifecycle update. if (emitters != null) { flushQueuedEmitterEffects() emitters.update(deltaTimeSeconds) } - // 5) Particle simulation. + // 6) Particle simulation. if (particles != null) { particles.update(deltaTimeSeconds) particles.updateSubstrate(updatedSubstrate) } - // 6) Flow field advection. + // 7) Flow field advection. if (flow != null) { flow.update(deltaTimeSeconds) updatedSubstrate = flow.updateSubstrate(updatedSubstrate) } - // 7) Waveform sampling. + // 8) Waveform sampling. waveform?.update(updatedSubstrate, agents, flow, deltaTimeSeconds) - // 8) Camera orbit. + // 9) Camera orbit. val camera = cameraOrbit?.update(deltaTimeSeconds) substrateState = updatedSubstrate @@ -265,7 +302,8 @@ class CognitiveSceneRuntime( cameraTransform = camera?.toCameraTransform(), emitterStates = emitters?.instances?.map { it.toEmitterState() } ?: emptyList(), choreographyPhase = dominantPhase(sortedAgents), - atmosphere = atmosphereState, + atmosphere = atmosphereChoreographer?.currentState, + atmosphereTransition = atmosphereChoreographer?.activeTransition, ) } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt index 189025d..89635dc 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt @@ -12,6 +12,7 @@ import link.socket.phosphor.math.Vector3 import link.socket.phosphor.render.Camera import link.socket.phosphor.signal.AgentVisualState import link.socket.phosphor.signal.AtmosphereState +import link.socket.phosphor.signal.AtmosphereTransition import link.socket.phosphor.signal.CognitivePhase /** @@ -33,6 +34,7 @@ data class SceneSnapshot( val emitterStates: List, val choreographyPhase: CognitivePhase, val atmosphere: AtmosphereState? = null, + val atmosphereTransition: AtmosphereTransition? = null, ) { init { if (waveformHeightField != null) { @@ -71,6 +73,7 @@ data class SceneSnapshot( if (emitterStates != other.emitterStates) return false if (choreographyPhase != other.choreographyPhase) return false if (atmosphere != other.atmosphere) return false + if (atmosphereTransition != other.atmosphereTransition) return false return true } @@ -91,6 +94,7 @@ data class SceneSnapshot( result = 31 * result + emitterStates.hashCode() result = 31 * result + choreographyPhase.hashCode() result = 31 * result + (atmosphere?.hashCode() ?: 0) + result = 31 * result + (atmosphereTransition?.hashCode() ?: 0) return result } } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/timeline/Easing.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/timeline/Easing.kt index de14413..f1d989e 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/timeline/Easing.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/timeline/Easing.kt @@ -167,6 +167,46 @@ object Easing { if (t == 0f) 0f else 2f.pow(10 * t - 10) } + /** + * Eager arrival — fast initial acceleration, then settles quickly. + * + * Use for transitions where the system is eager to arrive at the new state: + * relief responses, snapping to attention. + */ + val eager: (Float) -> Float = { t -> 1f - (1f - t).pow(4f) } + + /** + * Reluctant departure — slow initial movement, accelerating toward the end. + * + * Use for transitions where the system resists the change initially: + * descending into uncertain states. + */ + val reluctant: (Float) -> Float = { t -> t.pow(3.5f) } + + /** + * Overshoot arrival — exceeds the target value briefly before settling. + * + * Matches the standard easeOutBack formulation with overshoot constant + * 1.70158. Use for arrivals that should feel like the system is excited or + * relieved to be there: landing in a ready state. + */ + val overshoot: (Float) -> Float = { t -> + val c1 = 1.70158f + val c3 = c1 + 1 + val t1 = t - 1 + 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1 + } + + /** + * Settled bidirectional ease — smooth without character bias. + * + * Use for transitions that should feel neither eager nor reluctant: + * defaults and neutral movements. + */ + val settled: (Float) -> Float = { t -> + if (t < 0.5f) 2f * t * t else 1f - (-2f * t + 2f).pow(2f) / 2f + } + /** * Get an easing function by name. */ @@ -190,6 +230,10 @@ object Easing { "easeoutcirc" -> easeOutCirc "easeinexpo" -> easeInExpo "easeoutexpo" -> easeOutExpo + "eager" -> eager + "reluctant" -> reluctant + "overshoot" -> overshoot + "settled" -> settled else -> null } @@ -207,5 +251,6 @@ object Easing { "easeOutBounce", "easeInCirc", "easeOutCirc", "easeInExpo", "easeOutExpo", + "eager", "reluctant", "overshoot", "settled", ) } diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographerTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographerTest.kt new file mode 100644 index 0000000..d14202a --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographerTest.kt @@ -0,0 +1,221 @@ +package link.socket.phosphor.choreography + +import kotlin.math.pow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import link.socket.phosphor.palette.AtmospherePresets +import link.socket.phosphor.signal.AtmospherePattern +import link.socket.phosphor.timeline.Easing + +class AtmosphereChoreographerTest { + private val tolerance = 1e-4f + + @Test + fun `update with no active transition returns currentState unchanged`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + + val result = choreographer.update(0.1f) + + assertEquals(AtmospherePresets.IDLE, result) + assertEquals(AtmospherePresets.IDLE, choreographer.currentState) + assertNull(choreographer.activeTransition) + } + + @Test + fun `update with no active transition advances phases by dt times frequency`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + val expectedPulse = 0.25f * AtmospherePresets.IDLE.pulseFrequency + val expectedPattern = 0.25f * AtmospherePresets.IDLE.patternSpeed + + choreographer.update(0.25f) + + assertEquals(expectedPulse, choreographer.pulsePhase, tolerance) + assertEquals(expectedPattern, choreographer.patternPhase, tolerance) + } + + @Test + fun `setAtmosphere from idle to listening resolves to tabled spec`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + + choreographer.setAtmosphere(AtmospherePresets.LISTENING, targetPresetName = "listening") + + val transition = assertNotNull(choreographer.activeTransition) + assertEquals(0.6f, transition.durationSeconds, tolerance) + assertEquals("eager", transition.easingName) + assertEquals("idle", transition.fromPresetName) + assertEquals("listening", transition.toPresetName) + } + + @Test + fun `update at half duration produces eased amplitude and linear crossfade weight`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + choreographer.setAtmosphere(AtmospherePresets.LISTENING, targetPresetName = "listening") + + val state = choreographer.update(0.3f) + + val linear = 0.5f + val eased = Easing.eager(linear) + val expectedPulseAmplitude = + AtmospherePresets.IDLE.pulseAmplitude + + (AtmospherePresets.LISTENING.pulseAmplitude - AtmospherePresets.IDLE.pulseAmplitude) * eased + assertEquals(expectedPulseAmplitude, state.pulseAmplitude, tolerance) + assertEquals(linear, choreographer.patternBlend, tolerance) + val transition = assertNotNull(choreographer.activeTransition) + assertEquals(linear, transition.progressLinear, tolerance) + assertEquals(eased, transition.progressEased, tolerance) + assertTrue(choreographer.pulsePhase > 0f) + assertTrue(choreographer.patternPhase > 0f) + } + + @Test + fun `activeTransition is null before setAtmosphere and after completion`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + assertNull(choreographer.activeTransition) + + choreographer.setAtmosphere(AtmospherePresets.LISTENING, targetPresetName = "listening") + assertNotNull(choreographer.activeTransition) + + choreographer.update(0.6f) + assertNull(choreographer.activeTransition) + assertEquals(AtmospherePresets.LISTENING, choreographer.currentState) + } + + @Test + fun `interrupting transition starts new transition from current interpolated state`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + choreographer.setAtmosphere(AtmospherePresets.LISTENING, targetPresetName = "listening") + choreographer.update(0.3f) + val midState = choreographer.currentState + + choreographer.setAtmosphere(AtmospherePresets.THINKING, targetPresetName = "thinking") + + val transition = assertNotNull(choreographer.activeTransition) + assertEquals(midState, transition.from) + assertEquals(AtmospherePresets.THINKING, transition.to) + assertEquals("thinking", transition.toPresetName) + } + + @Test + fun `setAtmosphere does not reset phase accumulators`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + choreographer.update(0.5f) + val pulseBefore = choreographer.pulsePhase + val patternBefore = choreographer.patternPhase + + choreographer.setAtmosphere(AtmospherePresets.LISTENING, targetPresetName = "listening") + + assertEquals(pulseBefore, choreographer.pulsePhase, tolerance) + assertEquals(patternBefore, choreographer.patternPhase, tolerance) + } + + @Test + fun `color crossfade snapshots set when bipolar transition involves distinct hues`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + + choreographer.setAtmosphere(AtmospherePresets.UNCERTAIN, targetPresetName = "uncertain") + + val from = assertNotNull(choreographer.colorFromSnapshot) + val to = assertNotNull(choreographer.colorToSnapshot) + assertEquals(AtmospherePresets.IDLE.primaryHue, from.primaryHue, tolerance) + assertEquals(AtmospherePresets.IDLE.bipolarStrength, from.bipolarStrength, tolerance) + assertEquals(AtmospherePresets.UNCERTAIN.primaryHue, to.primaryHue, tolerance) + assertEquals(AtmospherePresets.UNCERTAIN.bipolarStrength, to.bipolarStrength, tolerance) + } + + @Test + fun `color crossfade snapshots null when no endpoint has bipolar strength`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + + choreographer.setAtmosphere(AtmospherePresets.LISTENING, targetPresetName = "listening") + + assertNull(choreographer.colorFromSnapshot) + assertNull(choreographer.colorToSnapshot) + } + + @Test + fun `eager easing is front-loaded with expected value at quarter progress`() { + val expected = 1f - (1f - 0.25f).pow(4f) + assertEquals(expected, Easing.eager(0.25f), tolerance) + assertEquals(0f, Easing.eager(0f), tolerance) + assertEquals(1f, Easing.eager(1f), tolerance) + assertTrue(Easing.eager(0.25f) > 0.25f, "eager should be ahead of linear early") + } + + @Test + fun `reluctant easing is back-loaded with expected value at three-quarter progress`() { + val expected = 0.75f.pow(3.5f) + assertEquals(expected, Easing.reluctant(0.75f), tolerance) + assertEquals(0f, Easing.reluctant(0f), tolerance) + assertEquals(1f, Easing.reluctant(1f), tolerance) + assertTrue(Easing.reluctant(0.5f) < 0.5f, "reluctant should lag linear at midpoint") + } + + @Test + fun `overshoot easing exceeds target before settling`() { + assertEquals(0f, Easing.overshoot(0f), tolerance) + assertEquals(1f, Easing.overshoot(1f), tolerance) + val late = Easing.overshoot(0.8f) + assertTrue(late > 1f, "overshoot should exceed 1 before settling; got $late") + } + + @Test + fun `settled easing matches piecewise quadratic at known points`() { + assertEquals(0f, Easing.settled(0f), tolerance) + assertEquals(0.5f, Easing.settled(0.5f), tolerance) + assertEquals(1f, Easing.settled(1f), tolerance) + // At t=0.25 (first half): 2 * 0.25^2 = 0.125 + assertEquals(0.125f, Easing.settled(0.25f), tolerance) + // At t=0.75 (second half): 1 - (-2*0.75 + 2)^2 / 2 = 1 - 0.5^2/2 = 0.875 + assertEquals(0.875f, Easing.settled(0.75f), tolerance) + } + + @Test + fun `all four named easings resolve via byName`() { + assertNotNull(Easing.byName("eager")) + assertNotNull(Easing.byName("reluctant")) + assertNotNull(Easing.byName("overshoot")) + assertNotNull(Easing.byName("settled")) + assertNotNull(Easing.byName("EAGER")) + } + + @Test + fun `pattern-changing transition sets patternFrom and blends linearly`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + choreographer.setAtmosphere(AtmospherePresets.LISTENING, targetPresetName = "listening") + + assertEquals(AtmospherePattern.LONGITUDE, choreographer.patternFrom) + + choreographer.update(0.3f) + assertEquals(AtmospherePattern.PLASMA, choreographer.currentState.pattern) + assertEquals(0.5f, choreographer.patternBlend, tolerance) + + choreographer.update(0.3f) + assertNull(choreographer.patternFrom) + assertEquals(AtmospherePattern.PLASMA, choreographer.currentState.pattern) + } + + @Test + fun `non-pattern-changing transition leaves patternFrom null`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.THINKING) + + choreographer.setAtmosphere(AtmospherePresets.UNCERTAIN, targetPresetName = "uncertain") + + assertNull(choreographer.patternFrom) + assertEquals(AtmospherePattern.SPIRAL, choreographer.currentState.pattern) + } + + @Test + fun `unknown transition pair falls back to default spec`() { + val choreographer = AtmosphereChoreographer(AtmospherePresets.IDLE) + val customTarget = AtmospherePresets.IDLE.copy(pulseAmplitude = 0.99f) + + choreographer.setAtmosphere(customTarget) + + val transition = assertNotNull(choreographer.activeTransition) + assertEquals(AtmosphereChoreographer.DefaultSpec.durationSeconds, transition.durationSeconds, tolerance) + assertEquals(AtmosphereChoreographer.DefaultSpec.easingName, transition.easingName) + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt index 40073c6..1508bed 100644 --- a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt @@ -63,7 +63,7 @@ class CognitiveSceneRuntimeTest { } @Test - fun `setAtmosphere replaces atmosphere before next snapshot`() { + fun `setAtmosphere drives transition to completion across updates`() { val runtime = CognitiveSceneRuntime( SceneConfiguration( @@ -74,10 +74,55 @@ class CognitiveSceneRuntimeTest { ) runtime.setAtmosphere(AtmospherePresets.THINKING) - val snapshot = runtime.update(0.016f) + val midSnapshot = runtime.update(0.1f) + assertTrue(midSnapshot.atmosphere != AtmospherePresets.THINKING) + assertTrue(midSnapshot.atmosphereTransition != null) + val finalSnapshot = runtime.update(2f) assertEquals(AtmospherePresets.THINKING, runtime.currentAtmosphere) - assertEquals(AtmospherePresets.THINKING, snapshot.atmosphere) + assertEquals(AtmospherePresets.THINKING, finalSnapshot.atmosphere) + assertNull(finalSnapshot.atmosphereTransition) + } + + @Test + fun `setAtmospherePreset resolves by name and triggers transition`() { + val runtime = + CognitiveSceneRuntime( + SceneConfiguration( + width = 8, + height = 6, + enableAtmosphere = true, + ), + ) + + runtime.setAtmospherePreset("listening") + val snapshot = runtime.update(0.0f) + + val transition = + requireNotNull(snapshot.atmosphereTransition) { + "atmosphereTransition should be populated immediately after setAtmospherePreset" + } + assertEquals("idle", transition.fromPresetName) + assertEquals("listening", transition.toPresetName) + assertEquals("eager", transition.easingName) + } + + @Test + fun `setAtmospherePreset throws on unknown name`() { + val runtime = + CognitiveSceneRuntime( + SceneConfiguration( + width = 8, + height = 6, + enableAtmosphere = true, + ), + ) + + val failure = + assertFailsWith { + runtime.setAtmospherePreset("nonexistent") + } + assertEquals("Unknown atmosphere preset: 'nonexistent'", failure.message) } @Test @@ -96,7 +141,8 @@ class CognitiveSceneRuntimeTest { } assertEquals( - "Atmosphere subsystem not enabled in SceneConfiguration. Set enableAtmosphere = true to use setAtmosphere.", + "Atmosphere subsystem not enabled in SceneConfiguration. " + + "Set enableAtmosphere = true to use setAtmosphere.", failure.message, ) }